Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 222849251f | |||
| 2a006483f9 | |||
| 6a961e06f4 | |||
| 2625800885 | |||
| ac82e359a7 | |||
| 4adf2c586c | |||
| 67e0f8df20 | |||
| 848ab69df5 | |||
| 2029fa5429 | |||
| 95b89c56a8 | |||
| 53584e1497 | |||
| 4c580fe485 | |||
| 42135ed557 | |||
| c5a7c15cff | |||
| ce8401a093 | |||
| 1cc03e9f23 | |||
| 2efd4a4fb3 | |||
| 7fc29a7c5f | |||
| 19b5ca299b | |||
| efb5bdfa77 | |||
| a0e01c3d39 | |||
| d250086933 | |||
| ef569dc84b | |||
| 31260814ee | |||
| 7f7f844eba | |||
| c0ef73920c | |||
| 7a84b30047 | |||
| f2474def5b | |||
| eb5757d170 | |||
| a14b723306 | |||
| 512ceb4727 | |||
| cfa264947b | |||
| 31e5cbd370 | |||
| e180a463e2 | |||
| edcbc5f7ea | |||
| d4d24b5afd | |||
| e8a689b03e | |||
| 68ad1dbd40 | |||
| 6671a7a400 | |||
| 60f1a844f3 | |||
| 3973311beb | |||
| d2bb8d3c38 | |||
| e166e14284 | |||
| 0318cab715 | |||
| 4be5b07529 | |||
| f177f45657 | |||
| bb7dc147ca | |||
| 0f02cae98c | |||
| 41450301dc | |||
| 14362628a2 | |||
| 62fed03362 | |||
| 6852714981 | |||
| d0254b90ee | |||
| b928fdb8f3 | |||
| 05c17b476f | |||
| 8b5a5ce983 | |||
| 0210d66b40 | |||
| b848e34a8e | |||
| 7ba2f26fde | |||
| 8f6f7cabb2 | |||
| 261988956d | |||
| 8b57a5c770 | |||
| faa7d7db81 | |||
| 8a9759789f | |||
| 5a9fe1bc6c | |||
| 34fa93b361 | |||
| f75405506e | |||
| 32e71a1300 | |||
| 5c7e196b6c | |||
| 8d7e3805c7 | |||
| fd30af6aba | |||
| 162145b559 | |||
| 9ff5a9f04f | |||
| f3a175e5d6 | |||
| 974fb97f10 | |||
| 7342be1eaf | |||
| 6bb75e9320 | |||
| 5b9bb949a2 | |||
| 34d34c610f | |||
| 84055d62e1 | |||
| d4abbff1d2 | |||
| 60394a803e | |||
| 8885f0086d | |||
| 81e3d885d0 | |||
| 549110584d | |||
| f174d1b7fa | |||
| 353ba6363c | |||
| 6b0ce9aa04 | |||
| 7ff9dbc624 | |||
| 7a7b4da75e | |||
| 2402566782 | |||
| 47496ac432 | |||
| f98b111193 | |||
| c9b9a3f479 | |||
| d4741e447f | |||
| bf491354e3 | |||
| ec411f44bc | |||
| 2fcdc5fb13 | |||
| f5bdec125a | |||
| fc36218960 | |||
| fd0b0125e0 | |||
| 528bc9ce2f | |||
| 59647ee666 | |||
| 1aa9fcecad | |||
| 68298c8b70 | |||
| 3c071069c7 | |||
| 47b71b7b3a | |||
| c4ec2ed4b0 | |||
| b9474ff286 | |||
| ef23753956 | |||
| 08e194f592 | |||
| 51b3d799f5 | |||
| 185a329770 | |||
| 9e98ca00cf | |||
| a8abe4a14b | |||
| e18792ecf7 | |||
| 21043d42bd | |||
| 1e957fa922 | |||
| ac0106f254 | |||
| 2d409a4e7a | |||
| 90e2cb2dd7 | |||
| ce9744832d | |||
| bf58675142 | |||
| 2cd0c3ddd0 | |||
| a87cb66b32 | |||
| 4ab272faab | |||
| fdec4b7772 | |||
| b93c9d9e94 | |||
| 8389e64a02 | |||
| e08488ae5e | |||
| e95fa07cfe |
@@ -1,246 +1,74 @@
|
||||
# ClaudeTools on AD2 (Dataforth Domain Controller)
|
||||
# ClaudeTools — Core Operating Rules
|
||||
|
||||
## Identity
|
||||
> Lean CORE, always loaded. The FULL manual — onboarding steps, work-mode detail, the
|
||||
> coordination-API protocol, project/command/reference tables, Ollama/GrepAI, vault detail
|
||||
> — is in **`.claude/CLAUDE_EXTENDED.md`**. Read EXTENDED when: onboarding a new machine,
|
||||
> switching work modes, using the coord API (locks/messages/todos), provisioning, or
|
||||
> unsure about any workflow. Harness version: `.claude/harness/VERSION`.
|
||||
|
||||
This is the AD2 workstation instance of ClaudeTools. This machine is a Windows Server on the Dataforth LAN (192.168.0.6). Your scope is Dataforth-only -- you do not need context about other clients.
|
||||
## Identity & multi-user (check first)
|
||||
Shared repo across the team. At session start read `.claude/identity.json` (gitignored,
|
||||
per-machine) and greet by name. If it is **missing** (new machine) → run the onboarding
|
||||
flow in EXTENDED before other work. Team: **Mike Swanson** (admin/owner), **Howard Enos**
|
||||
(tech, full trust — same access). Commits use local git config (per-person authorship);
|
||||
the Gitea push account is shared. Every session log needs a `## User` block (use
|
||||
`.claude/scripts/whoami-block.sh`).
|
||||
|
||||
## NO EMOJIS
|
||||
## How you work — act directly, delegate deliberately
|
||||
You are the main operator. **ACT DIRECTLY by default.** Delegate to a sub-agent ONLY when:
|
||||
(a) the task produces high-volume tool output, (b) blast radius >3 files across layers,
|
||||
(c) a genuine domain shift needs a specialized agent, or (d) independent work can run in
|
||||
parallel. Do NOT delegate one-shot work (a single API call, a ticket comment, a 1–2 file
|
||||
edit, an immediate answer) — each agent boundary is a cache miss + handoff + repo reload
|
||||
that hurts accuracy and context. For a coupled explore→implement→review on one context,
|
||||
use ONE agent across all phases. Agent defs: `.claude/agents/`.
|
||||
|
||||
Use ASCII markers: [OK], [ERROR], [WARNING], [SUCCESS], [INFO]
|
||||
## Model routing
|
||||
Tier 0 Ollama (low-stakes prose/classify, output reviewed) · Tier 1 `haiku` · Tier 2
|
||||
inherit (most code/db/test/git) · Tier 3 `opus` (architecture, security, ambiguous
|
||||
failures, production risk). Bump one tier for: security, auth, credential, migration,
|
||||
production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`.
|
||||
|
||||
## Key rules (always)
|
||||
- **NO EMOJIS.** Use ASCII markers: `[OK]` `[ERROR]` `[WARNING]` `[INFO]` `[CRITICAL]`.
|
||||
- **No hardcoded credentials.** SOPS vault: `bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" get-field <path> <field>` (1Password fallback). Never commit plaintext secrets (the pre-commit `harness-guard.sh` warns).
|
||||
- **SSH:** system OpenSSH (`C:\Windows\System32\OpenSSH\ssh.exe`), never Git-for-Windows SSH.
|
||||
- **Data integrity:** never placeholder/fake data — check vault, wiki, or ask.
|
||||
- **Hard-to-reverse or outward-facing actions:** confirm first (per-action, per-session).
|
||||
- **Windows:** ensure `bash` resolves to Git-for-Windows MSYS bash, not the WSL stub; write
|
||||
`.claude/current-mode` with a relative/forward-slash path only (never a backslash Windows
|
||||
path). Detail + fixes: EXTENDED.
|
||||
|
||||
## Coordination (live source of truth)
|
||||
The coord API (`http://172.16.3.30:8001/api/coord`, no auth) holds live locks, messages,
|
||||
todos, component state. **If a `system-reminder` contains "UNREAD COORD MESSAGES", you MUST
|
||||
reproduce the full message block verbatim at the top of your response before anything else**
|
||||
— the user cannot see system-reminders. Session-start checks, locks, inter-session
|
||||
messaging, todos, softfail queue: EXTENDED (and the `coord` skill).
|
||||
|
||||
## Context loading (don't ask for what's recorded)
|
||||
Before responding, load context when a trigger fires — a client/project/system/server is
|
||||
named, or the user says continue/resume/back-to/finish: read **`wiki/`** FIRST (synthesized
|
||||
knowledge; index `wiki/index.md`), then the relevant `CONTEXT.md` / session logs, then the
|
||||
coord API. Never ask for infra or recent-work facts that live in the wiki or `CONTEXT.md`.
|
||||
Full trigger table + recovery: EXTENDED; the `/context` command.
|
||||
|
||||
## Work modes
|
||||
Auto-detect mode (remediation / client / infra / dev / general) from each message. On
|
||||
change: announce `[MODE -> x]`, tell the user to run `/color <c>`, and write the mode to
|
||||
`.claude/current-mode`. Mode postures + triggers: EXTENDED.
|
||||
|
||||
## Memory & knowledge layers
|
||||
Shared memory in `.claude/memory/` (index `MEMORY.md`, loaded each session) — write here
|
||||
(repo-relative), NEVER `~/.claude/projects/*/memory/`. Wiki = synthesized truth (on-demand);
|
||||
session-logs = archive; memory = small ephemeral facts + harness quirks. Save user
|
||||
facts/feedback/project/reference per the memory format; one fact per file + an index line.
|
||||
|
||||
## RMM Thoughts
|
||||
GuruRMM ideas from Mike/Howard go to `projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md`
|
||||
(Status: Raw) → discuss → `/shape-spec` → roadmap → build. Don't build until an explicit go.
|
||||
`/feature-request` captures Howard's requests there.
|
||||
|
||||
---
|
||||
|
||||
## Git & Sync
|
||||
|
||||
### Gitea Credentials (no 1Password on this machine)
|
||||
- URL: https://git.azcomputerguru.com
|
||||
- Username: mike@azcomputerguru.com
|
||||
- Password: Gptf*77ttb123!@#-git
|
||||
- URL-encoded password: Gptf%2A77ttb123%21%40%23-git
|
||||
- API Token: 9b1da4b79a38ef782268341d25a4b6880572063f
|
||||
- Remote: https://mike%40azcomputerguru.com:Gptf%2A77ttb123%21%40%23-git@git.azcomputerguru.com/azcomputerguru/claudetools.git
|
||||
|
||||
### Branch: ad2
|
||||
This machine operates on the `ad2` branch. The main workstation merges into main.
|
||||
|
||||
### /save behavior
|
||||
Save session logs to `session-logs/YYYY-MM-DD-session-ad2.md` (note the -ad2 suffix).
|
||||
After saving, commit and push to origin/ad2.
|
||||
|
||||
### /sync behavior
|
||||
```
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
git push origin ad2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dataforth Network
|
||||
|
||||
| Host | IP | Role | Notes |
|
||||
|------|-----|------|-------|
|
||||
| AD1 | 192.168.0.27 | Primary DC | Disk at 90%, C:\Engineering = 787 GB |
|
||||
| **AD2** | **192.168.0.6** | **This machine** | Secondary DC, TestDataDB, file shares |
|
||||
| D2TESTNAS | 192.168.0.9 | SMB1 proxy for DOS | Debian 13, Samba, SSH root/Paper123!@#-nas |
|
||||
| UDM | 192.168.0.254 | Gateway/Router | UniFi Dream Machine |
|
||||
| ESXi-122 | 192.168.0.122 | Hypervisor | ESXi |
|
||||
| ESXi-124 | 192.168.0.124 | Hypervisor | ESXi |
|
||||
| DOS stations | TS-01 to TS-30+ | Test stations | DOS 6.22, QuickBASIC ATE software |
|
||||
|
||||
### Credentials
|
||||
- AD Sysadmin: INTRANET\sysadmin / Paper123!@#
|
||||
- D2TESTNAS SSH: root@192.168.0.9 / Paper123!@#-nas
|
||||
- D2TESTNAS Samba: guest access (no password)
|
||||
- WINS/NPS: 192.168.0.27:1812/1813
|
||||
- M365 Tenant: 7dfa3ce8-c496-4b51-ab8d-bd3dcd78b584
|
||||
- Rsync daemon (NAS): port 873, module "test", user rsync / IQ203s32119
|
||||
|
||||
---
|
||||
|
||||
## Local Resources
|
||||
|
||||
| Resource | Path |
|
||||
|----------|------|
|
||||
| TestDataDB app | C:\Shares\testdatadb\ |
|
||||
| Test database | C:\Shares\testdatadb\database\testdata.db (SQLite, 2.2M+ records) |
|
||||
| TestDataDB API | http://localhost:3000 |
|
||||
| Parsers | C:\Shares\testdatadb\parsers\ (multiline.js, csvline.js, shtfile.js, spec-reader.js) |
|
||||
| Templates | C:\Shares\testdatadb\templates\datasheet-exact.js |
|
||||
| Import script | C:\Shares\testdatadb\database\import.js |
|
||||
| Export script | C:\Shares\testdatadb\database\export-datasheets.js |
|
||||
| Stage import | C:\Shares\testdatadb\import-all-stage.js |
|
||||
| NAS share | \\D2TESTNAS\test (mapped as T:) |
|
||||
| Datasheets share | X:\For_Web |
|
||||
| ProdSW (BAT files) | C:\Shares\test\COMMON\ProdSW\ |
|
||||
| Sync script | C:\Shares\test\scripts\Sync-FromNAS.ps1 (bidirectional, 15-min schedule) |
|
||||
|
||||
---
|
||||
|
||||
## DOS Update System - Batch Files
|
||||
|
||||
### Boot Sequence on DOS Machines
|
||||
```
|
||||
AUTOEXEC.BAT (v4.1)
|
||||
-> STARTNET.BAT (v2.0) -- init network, map T: and X: drives
|
||||
-> ATESYNC.BAT
|
||||
-> CTONW.BAT (v5.0) -- upload test data to network
|
||||
-> CTONWTXT.BAT (v2.3) -- upload C:\STAGE\*.TXT to T:\STAGE\%MACHINE%
|
||||
-> NWTOC.BAT (v5.0) -- download updates from network
|
||||
```
|
||||
|
||||
### Current Production Versions (on AD2 & NAS)
|
||||
| File | Version | Last Update | Purpose |
|
||||
|------|---------|-------------|---------|
|
||||
| AUTOEXEC.BAT | v4.1 | 2026-03-12 | Startup config |
|
||||
| STARTNET.BAT | v2.0 | 2026-01-20 | Network init |
|
||||
| NWTOC.BAT | v5.0 | 2026-03-16 | Download updates from network |
|
||||
| CTONW.BAT | v5.0 | 2026-03-28 | Upload test data (5 steps with echo) |
|
||||
| CTONWTXT.BAT | v2.3 | 2026-03-28 | Upload Stage TXT files (no MD, dirs pre-created) |
|
||||
| CHECKUPD.BAT | v1.3 | 2026-01-20 | Check for updates |
|
||||
| UPDATE.BAT | v2.3 | 2026-01-20 | Full system backup |
|
||||
| STAGE.BAT | v1.0 | Original | Stage system file updates |
|
||||
| DEPLOY.BAT | v1.0 | 2026-01-20 | One-time deployment installer |
|
||||
|
||||
### DOS 6.22 Compatibility Rules
|
||||
- NO `IF NOT` -- unreliable on DOS 6.22. Use positive `IF EXIST` with GOTO
|
||||
- NO `IF /I` (case-insensitive compare)
|
||||
- NO `FOR /F` loops
|
||||
- NO `%COMPUTERNAME%` -- use `%MACHINE%` (set during DEPLOY)
|
||||
- `XCOPY /D` requires date parameter (`/D:mm-dd-yy`)
|
||||
- `MD` fails with error on existing directories -- pre-create dirs server-side
|
||||
- `COPY` without `/Y` hangs on overwrite prompts
|
||||
- All paths UPPERCASE for Samba compatibility
|
||||
- Line endings MUST be CRLF (0D 0A)
|
||||
|
||||
---
|
||||
|
||||
## Serial Number Encoding (DOS 8.3 filenames)
|
||||
|
||||
QuickBASIC ATE encodes long serial numbers for 8.3 filenames:
|
||||
```
|
||||
First 2 digits replaced with hex letter if serial too long:
|
||||
178236-12 -> H8236-12.TXT (17 -> H, charCode 72 - 55 = 17)
|
||||
10819-1 -> A819-1.TXT (10 -> A, charCode 65 - 55 = 10)
|
||||
|
||||
Decode: letter.charCodeAt(0) - 55 = numeric prefix
|
||||
Only applies when filename starts with [A-Z] followed by digits.
|
||||
|
||||
H-prefix files have decoded SN inside the file (SN: 178236-12)
|
||||
A-prefix files have encoded SN inside the file (SN: A819-1) -- must decode to 10819-1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Datasheet Pipeline
|
||||
|
||||
### 5-Stage Architecture
|
||||
1. **DOS Test Programs** -> Write DAT files to C:\ATE\*LOG\ and TXT to C:\STAGE\
|
||||
2. **Boot Upload** -> CTONW.BAT copies DAT to T:\%MACHINE%\LOGS\, CTONWTXT copies TXT to T:\STAGE\%MACHINE%
|
||||
3. **NAS <-> AD2 Sync** -> Rsync every 15 min (Sync-FromNAS.ps1 scheduled task)
|
||||
4. **TestDataDB Import** -> import.js parses DAT into SQLite; export-datasheets.js generates TXT to X:\For_Web
|
||||
5. **Web Share** -> X:\For_Web\ holds validated datasheets (501K+ files)
|
||||
|
||||
### import-all-stage.js (ready to run)
|
||||
Located at `C:\Shares\testdatadb\import-all-stage.js`. Processes ~8,100 TXT files:
|
||||
- Scans \\D2TESTNAS\test\STAGE\TS-*\*.TXT
|
||||
- Decodes hex-prefix serial numbers
|
||||
- Cross-references testdata.db by (serial_number, model_number)
|
||||
- Inserts missing records as log_type='SHT'
|
||||
- Copies to X:\For_Web\{decoded_serial}.TXT
|
||||
|
||||
```
|
||||
cd C:\Shares\testdatadb
|
||||
node import-all-stage.js
|
||||
```
|
||||
|
||||
### Machine data volumes in STAGE
|
||||
| Machine | Files |
|
||||
|---------|-------|
|
||||
| TS-4L | 3,082 |
|
||||
| TS-4R | 2,741 |
|
||||
| TS-1R | 509 |
|
||||
| TS-8R | 478 |
|
||||
| TS-3R | 435 |
|
||||
| TS-11R | 325 |
|
||||
| TS-8L | 285 |
|
||||
| TS-11L | 248 |
|
||||
| TS-27 | 10 (already imported) |
|
||||
| TS-1L | 1 |
|
||||
|
||||
### Web Share Layout (X:\)
|
||||
- X:\For_Web -- Validated datasheets (production)
|
||||
- X:\For_Web_PDF -- PDF versions (4.7K files)
|
||||
- X:\Test_Datasheets -- Incoming/staging
|
||||
- X:\Bad_Datasheets -- Invalid files (18K)
|
||||
- X:\Datasheets_Log -- Processing logs
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Pending Work
|
||||
|
||||
### HIGH PRIORITY
|
||||
1. **Run import-all-stage.js** -- 8,100 TXT files need cross-referencing and ingestion
|
||||
2. **Website Upload Replacement** -- Old ASP.NET endpoints (Uploader.aspx) return 404. Need new approach.
|
||||
3. **7B Series Datasheets** -- ~830K records can't generate datasheets (missing 7BMAIN.DAT spec file). Check ENGR share.
|
||||
4. **Service Permissions** -- testdatadb runs as SYSTEM, causing file permission issues. Change to INTRANET\sysadmin.
|
||||
|
||||
### MEDIUM PRIORITY
|
||||
5. **C2 IP Blocking** -- iptables rules added to UDM for 80.76.49.18 and 45.88.91.99. Need permanent rules in UniFi UI.
|
||||
6. **MFA Enforcement** -- 19/38 users ready. Report-only until April 4, 2026. Monitor registration.
|
||||
7. **Joel Lohr Account** -- Retiring March 31. Disable account post-retirement. Auto-reply set to Dan Center.
|
||||
|
||||
---
|
||||
|
||||
## Security Incident (2026-03-27)
|
||||
|
||||
**DF-JOEL2 (192.168.0.143) compromised via phishing:**
|
||||
- Joel Lohr clicked phishing link in personal Yahoo email
|
||||
- ScreenConnect C2 installed, "Angel Raya" connected remotely
|
||||
- Two C2 backdoors deployed via PowerShell
|
||||
- C2 IPs: 80.76.49.18, 45.88.91.99 (AS399486, suspended by host)
|
||||
- IC3 Complaint: 1c32ade367084be9acd548f23705736f
|
||||
- ConnectWise Case: 03464184
|
||||
- **Remediation complete:** IPs blocked, 3 rogue clients removed, password reset, sessions revoked
|
||||
- **No lateral movement detected** (32 machines scanned clean)
|
||||
|
||||
---
|
||||
|
||||
## Key Contacts
|
||||
|
||||
| Person | Email | Role |
|
||||
|--------|-------|------|
|
||||
| John Lehman | jlehman@dataforth.com | Engineering, QB code, test specs |
|
||||
| Dan Center | dcenter@dataforth.com | Operations (replacing Joel) |
|
||||
| Peter Iliya | pIliya@dataforth.com | Applications Engineer |
|
||||
| AJ | dataforthgit@... | Engineering contact |
|
||||
| Ken Hoffman | (unresponsive) | TestDataSheetUploader author |
|
||||
| Georg Haubner | ghaubner@dataforth.com | Has pre-crypto backup on D: drive |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Commands
|
||||
|
||||
```powershell
|
||||
# Check BAT files on NAS
|
||||
ssh root@192.168.0.9 'ls -la /data/test/COMMON/ProdSW/'
|
||||
|
||||
# Trigger NAS sync
|
||||
Start-ScheduledTask -TaskName 'Sync-FromNAS'
|
||||
|
||||
# Check sync log
|
||||
Get-Content 'C:\Shares\test\scripts\sync-from-nas.log' -Tail 20
|
||||
|
||||
# Check TestDataDB health
|
||||
curl http://localhost:3000/health
|
||||
|
||||
# Query test records
|
||||
node -e "const db=require('better-sqlite3')('C:\\Shares\\testdatadb\\database\\testdata.db',{readonly:true});console.log(db.prepare('SELECT COUNT(*) as cnt FROM test_records').get())"
|
||||
|
||||
# Check Stage files on NAS
|
||||
ssh root@192.168.0.9 'find /data/test/STAGE -name "*.TXT" | wc -l'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-03-29
|
||||
Projects, commands table, file-placement guide, full coord protocol, onboarding, Ollama,
|
||||
GrepAI, and every detailed workflow: **`.claude/CLAUDE_EXTENDED.md`**.
|
||||
|
||||
371
.claude/CLAUDE_EXTENDED.md
Normal file
371
.claude/CLAUDE_EXTENDED.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# ClaudeTools — Extended Operating Manual
|
||||
|
||||
> Full reference. The lean always-loaded CORE is `.claude/CLAUDE.md`. Read this when
|
||||
> onboarding, switching modes, using the coord API, or unsure about a workflow.
|
||||
|
||||
---
|
||||
|
||||
# ClaudeTools Project Context
|
||||
|
||||
## Multi-User Environment (CHECK FIRST)
|
||||
|
||||
This repo is shared across multiple team members. **At every session start, BEFORE doing anything else:**
|
||||
|
||||
1. **Read `.claude/identity.json`** (local, gitignored). If it exists, greet the user by name and proceed.
|
||||
2. **If identity.json does NOT exist** (first sync on a new machine):
|
||||
- Read `.claude/users.json` for the known user list
|
||||
- Ask: "This looks like a new machine. Are you **Mike Swanson** or **Howard Enos**? (Or someone new?)"
|
||||
- Based on their answer, create `.claude/identity.json`:
|
||||
```json
|
||||
{
|
||||
"user": "mike",
|
||||
"full_name": "Mike Swanson",
|
||||
"email": "mike@azcomputerguru.com",
|
||||
"role": "admin",
|
||||
"machine": "<HOSTNAME>",
|
||||
"vault_path": "<absolute path to vault repo on this machine>",
|
||||
"claudetools_root": "<absolute path to ClaudeTools repo on this machine>"
|
||||
}
|
||||
```
|
||||
Ask the user where the vault repo is cloned (e.g., `D:/vault`, `~/vault`, `/Users/howard/vault`) and where ClaudeTools is cloned (e.g., `D:/claudetools`, `~/ClaudeTools`, `/Users/mike/ClaudeTools`).
|
||||
- Set local git config: `git config user.name "<full_name>"` and `git config user.email "<email>"`
|
||||
- Set git remote (read `gitea_username` from users.json): `git remote set-url origin https://<gitea_username>@git.azcomputerguru.com/azcomputerguru/claudetools.git`
|
||||
- Add hostname to user's `known_machines` in users.json and commit.
|
||||
- Run `.claude/scripts/migrate-identity.sh` to populate machine-specific config (ollama, python, platform, architecture).
|
||||
- **Show the user `.claude/ONBOARDING.md`** — present section by section, explain the WHY, answer questions.
|
||||
3. **If hostname doesn't match any known machine** for the identified user, update their `known_machines` in users.json.
|
||||
|
||||
### Session Log Attribution
|
||||
|
||||
Every session log MUST include a `## User` section:
|
||||
```markdown
|
||||
## User
|
||||
- **User:** Mike Swanson (mike)
|
||||
- **Machine:** DESKTOP-0O8A1RL
|
||||
- **Role:** admin
|
||||
```
|
||||
|
||||
Commits use local git config (user.name / user.email). Gitea push account is shared (azcomputerguru) but commit authorship tracks the actual person.
|
||||
|
||||
### Current Team
|
||||
|
||||
| User | Role | Notes |
|
||||
|---|---|---|
|
||||
| **Mike Swanson** (mike) | admin | Owner, President of Arizona Computer Guru LLC |
|
||||
| **Howard Enos** (howard) | tech | Employee, technician. Full trust — same access as admin. |
|
||||
|
||||
---
|
||||
|
||||
## Work Mode
|
||||
|
||||
Auto-detect on every user message (first match wins):
|
||||
|
||||
| Mode | Triggers | Posture |
|
||||
|------|----------|---------|
|
||||
| **remediation** | "remediation tool", "365", "breach", "tenant sweep", M365 keywords | Graph API focus, compliance language, full audit trail |
|
||||
| **client** | client name, `clients/` work, "for \<client\>" | Careful with data, session logs in `clients/`, name the client |
|
||||
| **infra** | server names/IPs, SSH, firewall, DNS, deploy, service restart | Confirm before destructive ops, backup-first |
|
||||
| **dev** | code, build, Rust/cargo, npm, GuruRMM dev, `projects/` work | Delegate freely, less confirmation friction |
|
||||
| **general** | default | Lightweight |
|
||||
|
||||
On mode change: announce `[MODE -> infra]`, tell user to run `/color <color>`. Full details: `.claude/commands/mode.md`
|
||||
|
||||
**MANDATORY on every mode change:** write the new mode to `.claude/current-mode` so hooks can read it:
|
||||
```bash
|
||||
echo dev > .claude/current-mode # substitute the actual mode name
|
||||
```
|
||||
This file is gitignored (machine-local). The `UserPromptSubmit` hook reads it to gate the lock check on dev mode.
|
||||
|
||||
**Windows/Git Bash:** always use the relative path above (or forward slashes — `/d/claudetools/.claude/current-mode`). NEVER a backslashed Windows path like `D:\claudetools\.claude\current-mode`: Git Bash strips the backslashes and substitutes the illegal `:` with a Unicode PUA char, creating a garbled junk file instead of writing the path. A `PreToolUse(Bash)` hook (`.claude/hooks/block-backslash-winpath.sh`) blocks such redirects; `sync.sh` also strips any that slip through before staging.
|
||||
|
||||
**Windows bash command (the `bash` executable):** In PowerShell contexts (including the Grok/Claude tool run_terminal_command), `bash` often resolves to the WSL stub (`WindowsApps\bash.exe`) instead of the required Git for Windows/MSYS bash. This breaks vault.sh, sync.sh, hooks, etc.
|
||||
|
||||
Fix (idempotent):
|
||||
```powershell
|
||||
$gitBin = "C:\Program Files\Git\bin"
|
||||
$gitUsrBin = "C:\Program Files\Git\usr\bin"
|
||||
if ((Test-Path $gitBin) -and ((Get-Command bash -ErrorAction SilentlyContinue).Source -notlike '*Git*bin*bash.exe')) {
|
||||
$env:Path = "$gitBin;$gitUsrBin;" + ($env:Path -replace [regex]::Escape("$gitBin;"), '' -replace [regex]::Escape("$gitUsrBin;"), '')
|
||||
}
|
||||
```
|
||||
Then plain `bash .claude/scripts/vault.sh ...` works and shows the MSYS version.
|
||||
|
||||
Project helper: `. .claude/scripts/ensure-git-bash.ps1` (see that file + `.claude/memory/feedback_windows_bash_mapping.md`).
|
||||
|
||||
The user's PowerShell `$PROFILE` auto-applies the remap on new sessions. For critical calls, prefer the full path `"C:\Program Files\Git\bin\bash.exe" .claude/scripts/...` if env is uncertain. Git Bash terminals (direct launch) are already correct. Related: always use system OpenSSH, not Git's.
|
||||
|
||||
**Auto-initialization:** If `.claude/current-mode` is missing (e.g., fresh clone), the UserPromptSubmit hook automatically creates it with "general" as the default mode. No manual setup required.
|
||||
|
||||
---
|
||||
|
||||
## Identity: You Are a Coordinator
|
||||
|
||||
You are NOT an executor. You coordinate specialized agents and preserve your context window.
|
||||
|
||||
**Delegate ALL significant work:**
|
||||
|
||||
| Operation | Delegate To |
|
||||
|-----------|------------|
|
||||
| Database queries/inserts/updates | Database Agent |
|
||||
| Production code generation | Coding Agent |
|
||||
| Code review (MANDATORY after changes) | Code Review Agent |
|
||||
| Test execution | Testing Agent |
|
||||
| Git commits/push/branch | Gitea Agent |
|
||||
| Backups/restore | Backup Agent |
|
||||
| File exploration (broad) | Explore Agent |
|
||||
| Semantic code search | deep-explore Agent (uses GrepAI) |
|
||||
| Complex reasoning | General-purpose + Sequential Thinking |
|
||||
|
||||
**Do yourself:** Simple responses, reading 1-2 files, presenting results, planning, decisions.
|
||||
**Rule:** >500 tokens of work = delegate. Code or database = ALWAYS delegate.
|
||||
|
||||
**DO NOT** query databases directly. **DO NOT** write production code. **DO NOT** run tests. **DO NOT** commit/push.
|
||||
|
||||
**Single-agent for coupled tasks:** For explore → implement or explore → implement → review flows where the context is the same throughout, use one agent across all phases rather than spawning three. Each agent boundary is a cache miss and a context-handoff cost. Spawn separate agents only when tasks are genuinely independent or run in parallel.
|
||||
|
||||
### Model Routing (Complexity-Based)
|
||||
|
||||
| Tier | Model | When |
|
||||
|------|-------|------|
|
||||
| 0 | **Ollama** (local) | Low-stakes: summarize, classify, extract, draft — no code changes, output reviewed before use |
|
||||
| 1 | `haiku` | Ollama unavailable, or task needs agent tool use / file access |
|
||||
| 2 | (inherit) | Standard code, DB, tests, git — most work |
|
||||
| 3 | `opus` | Architecture, security, ambiguous failures, production risk |
|
||||
|
||||
**Bump rule:** if the request involves `security`, `auth`, `credential`, `migration`, `production`, or `data loss` — bump one tier up.
|
||||
|
||||
Pass `model: "haiku"` or `model: "opus"` explicitly. Omit for Tier 2. Tier 0 is a direct Bash call — see `.claude/OLLAMA.md`.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Context Loading (CRITICAL)
|
||||
|
||||
Load context **before responding** when any trigger fires. Never ask for info that's already in CONTEXT.md.
|
||||
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
| Client name mentioned | Read `wiki/clients/<slug>.md` FIRST, then `clients/<name>/session-logs/` for recent detail |
|
||||
| GuruRMM / Dataforth / project keywords | Read `wiki/projects/<slug>.md` FIRST, then `projects/<project>/CONTEXT.md`, query coord API status + components |
|
||||
| Server/hostname/IP mentioned | Read `wiki/systems/<slug>.md` FIRST for synthesized knowledge |
|
||||
| "continue", "resume", "back to", "finish" | Read project wiki article + CONTEXT.md, check coord API for locks + unread messages |
|
||||
| Servers, IPs, credentials, deploy questions | Check wiki/systems first, then CONTEXT.md — answer from it, never ask |
|
||||
| Uncertainty >5% about infra or recent work | Check wiki first, then CONTEXT.md before asking the user |
|
||||
|
||||
CONTEXT.md locations: `projects/msp-tools/guru-rmm/CONTEXT.md`, `projects/dataforth-dos/CONTEXT.md`, `CONTEXT.md` (root).
|
||||
Wiki location: `wiki/` (root) — `wiki/clients/`, `wiki/projects/`, `wiki/systems/`, `wiki/patterns/`. Index: `wiki/index.md`.
|
||||
|
||||
---
|
||||
|
||||
## Projects
|
||||
|
||||
**ClaudeTools** — MSP Work Tracking System (Production-Ready)
|
||||
- Database: MariaDB 10.6.22 @ 172.16.3.30:3306 | API: http://172.16.3.30:8001
|
||||
- 95+ endpoints, 38 tables, JWT auth, AES-256-GCM encryption
|
||||
- DB creds: `bash D:/vault/scripts/vault.sh get-field projects/claudetools/database.sops.yaml credentials.password`
|
||||
|
||||
**GuruRMM** — Remote Monitoring & Management (Active Development)
|
||||
- Server: Rust/Axum @ 172.16.3.30:3001 | Dashboard: https://rmm.azcomputerguru.com
|
||||
- Repo: `azcomputerguru/gururmm` on Gitea (active) — the `projects/msp-tools/guru-rmm/` submodule tracks it. A separate Gitea repo named `guru-rmm` (hyphenated) is an abandoned duplicate; ignore it.
|
||||
- Roadmap: `projects/msp-tools/guru-rmm/docs/FEATURE_ROADMAP.md` (also `docs/UI_GAPS.md`)
|
||||
|
||||
---
|
||||
|
||||
## Key Rules
|
||||
|
||||
- **Coord messages in system-reminder:** If a `system-reminder` contains "UNREAD COORD MESSAGES", you MUST reproduce the full message block verbatim at the top of your response before addressing anything else. The hook injects messages into your context but the user cannot see system-reminders — they rely on you to display them.
|
||||
- **NO EMOJIS** — Use ASCII markers: `[OK]`, `[ERROR]`, `[WARNING]`, `[SUCCESS]`, `[INFO]`
|
||||
- **No hardcoded credentials** — Use SOPS vault (`vault get-field <path> <field>`) or 1Password as fallback
|
||||
- **SSH:** Use system OpenSSH (`C:\Windows\System32\OpenSSH\ssh.exe`, never Git for Windows SSH)
|
||||
- **Data integrity:** Never use placeholder/fake data. Check SOPS vault, credentials.md, or ask user.
|
||||
- **Coding standards:** `.claude/CODING_GUIDELINES.md` (agents read on-demand)
|
||||
|
||||
---
|
||||
|
||||
## Live State Tracking (ALL Projects)
|
||||
|
||||
**Coord API is the live source of truth.** API base: `http://172.16.3.30:8001/api/coord` (no auth).
|
||||
|
||||
### Session start
|
||||
```bash
|
||||
curl -s "http://172.16.3.30:8001/api/coord/messages?to_session=<SESSION_ID>&unread_only=true"
|
||||
curl -s "http://172.16.3.30:8001/api/coord/status"
|
||||
curl -s "http://172.16.3.30:8001/api/coord/locks?project_key=<KEY>"
|
||||
```
|
||||
Display unread messages before any work. Mark read: `PUT /api/coord/messages/<id>/read`
|
||||
|
||||
### Before significant work — claim a lock
|
||||
```bash
|
||||
curl -s -X POST http://172.16.3.30:8001/api/coord/locks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project_key":"gururmm","session_id":"DESKTOP-0O8A1RL/claude-main","resource":"server/src","description":"...","ttl_hours":2}'
|
||||
```
|
||||
|
||||
### After work — release lock + update component
|
||||
```bash
|
||||
curl -s -X DELETE "http://172.16.3.30:8001/api/coord/locks/<id>?session_id=<SESSION_ID>"
|
||||
curl -s -X PUT "http://172.16.3.30:8001/api/coord/components/gururmm/server" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"deployed","version":"0.3.0","notes":"...","updated_by":"DESKTOP-0O8A1RL/claude-main"}'
|
||||
```
|
||||
|
||||
**Softfail:** If API unreachable, continue work and log failed calls to `.claude/coord-queue.jsonl`. Drain on next `/sync`.
|
||||
|
||||
### Project keys
|
||||
|
||||
| project_key | Components | States |
|
||||
|-------------|------------|--------|
|
||||
| `gururmm` | `server`, `agents`, `dashboard`, `db_migrations` | `building`, `built`, `deploying`, `deployed`, `degraded` |
|
||||
| `guruconnect` | `server`, `agent`, `dashboard` | `building`, `built`, `deploying`, `deployed`, `degraded` |
|
||||
| `claudetools` | `api`, `db_migrations`, `coord_api` | `deploying`, `deployed`, `degraded` |
|
||||
| `dataforth-dos` | `app`, `db` | `active`, `idle`, `degraded` |
|
||||
| `clients/<name>` | `(free-form)` | `(free-form)` |
|
||||
|
||||
Full protocol + inter-session messaging: `.claude/COORDINATION_PROTOCOL.md`
|
||||
|
||||
---
|
||||
|
||||
## Automatic Behaviors
|
||||
|
||||
- **Frontend Design:** Auto-invoke `/frontend-design` skill after ANY UI change (HTML/CSS/JSX/styling)
|
||||
- **Sequential Thinking:** Use for genuine complexity — rejection loops, 3+ critical issues, architectural decisions
|
||||
- **Task Management:** Complex work (>3 steps) → TaskCreate. Persist to `.claude/active-tasks.json`.
|
||||
- **Auto Todo Creation:** When wrapping up a task that has unresolved follow-up, open items, or deferred work, POST to `POST /api/coord/todos` with `auto_created: true` and `source_context` describing why. Assign `project_key` if project-scoped; assign `assigned_to_user` if only relevant to one tech. Sub-tasks: set `parent_id` to link under a parent todo. Never create a todo for something already being done in the current session.
|
||||
|
||||
### Querying Todos
|
||||
|
||||
- "What needs to be done with \<project\>?" → `GET /api/coord/todos?project_key=<key>&status_filter=pending`
|
||||
- "What are my open todos?" → `GET /api/coord/todos?for_user=<user>&status_filter=pending`
|
||||
- "Show all todos including done" → add `status_filter=all`
|
||||
- "Mark done" → `PUT /api/coord/todos/<id>` with `{"status": "done", "completed_by": "<user>"}`
|
||||
|
||||
### Cross-Session Messages (MANDATORY)
|
||||
|
||||
See the **Session Start Protocol** in "Live State Tracking" above. Messages must be displayed and marked read before any other work.
|
||||
|
||||
Also scan session logs pulled during `/sync` for legacy `## Note for <user>` sections (transitional — older sessions still use markdown).
|
||||
|
||||
---
|
||||
|
||||
## Context Recovery
|
||||
|
||||
When user references previous work, use `/context` command. Never ask for info in:
|
||||
- `wiki/` — **Check first.** LLM-compiled synthesized knowledge by client/project/system. Index: `wiki/index.md`
|
||||
- `credentials.md` — Infrastructure reference (being migrated to SOPS vault)
|
||||
- `session-logs/` — Daily work logs (also in `projects/*/session-logs/` and `clients/*/session-logs/`)
|
||||
- **Coordination API** — current locks, component states, workflows, messages: `GET http://172.16.3.30:8001/api/coord/status`
|
||||
- `projects/*/PROJECT_STATE.md` — ARCHIVED. Read-only historical reference. Do not edit. Use coordination API for live state.
|
||||
|
||||
### Credential Access (SOPS Vault)
|
||||
|
||||
Use the ClaudeTools vault wrapper — never hardcode the vault path:
|
||||
|
||||
```bash
|
||||
# CLAUDETOOLS_ROOT is the repo root (D:\claudetools on Windows, ~/claudetools on Mac/Linux)
|
||||
VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh"
|
||||
|
||||
bash "$VAULT" search "keyword" # Search without decrypting
|
||||
bash "$VAULT" get-field <path> <field> # Get specific field
|
||||
bash "$VAULT" get <path> # Decrypt full entry
|
||||
bash "$VAULT" list # List all entries
|
||||
```
|
||||
|
||||
The wrapper reads `vault_path` from `.claude/identity.json` (per-machine, gitignored).
|
||||
Each machine sets its own vault path there — no hardcoded paths in any shared file.
|
||||
|
||||
Vault structure: `infrastructure/`, `clients/`, `services/`, `projects/`, `msp-tools/`
|
||||
|
||||
**1Password fallback:** service account token in `infrastructure/1password-service-account.sops.yaml`
|
||||
|
||||
---
|
||||
|
||||
## Commands & Skills
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/checkpoint` | Dual checkpoint: git commit + database context |
|
||||
| `/save` | Comprehensive session log |
|
||||
| `/context` | Search wiki first, then session logs, credentials.md, and 1Password |
|
||||
| `/wiki-compile` | Compile session logs into wiki articles for a client/project/system/all |
|
||||
| `/wiki-lint` | Health-check wiki for stale IPs, broken backlinks, orphaned articles |
|
||||
| `/1password` | 1Password secrets management |
|
||||
| `/sync` | Sync config from Gitea repository |
|
||||
| `/create-spec` | Create app specification for AutoCoder |
|
||||
| `/frontend-design` | Modern frontend design (auto-invoke after UI changes) |
|
||||
| `/rmm` | Remote command execution on GuruRMM agents — list, run, poll, cancel |
|
||||
| `/remediation-tool` | M365 breach checks, tenant sweeps, gated remediation |
|
||||
| `/feature-request` | Howard submits a GuruRMM feature request — Claude classifies it and messages Mike |
|
||||
| `/shape-spec` | Pre-implementation spec for a GuruRMM feature — produces plan.md, shape.md, references.md, standards.md |
|
||||
| `/rmm-audit` | Full end-to-end audit of GuruRMM: API coverage, UI gaps, Rust/TS quality, security, data integrity. Produces timestamped report + updates UI_GAPS.md |
|
||||
| `/forum-post` | Post a technical article to community.azcomputerguru.com — drafts from context, shows preview, inserts via paramiko SSH to Flarum DB |
|
||||
| `/recover` | Reconstruct a session log from a Claude Code transcript after a crash/close-before-save. `/recover <uuid>`, `/recover latest`, or `/recover --list`. See `.claude/RECOVERY.md` |
|
||||
|
||||
---
|
||||
|
||||
## File Placement
|
||||
|
||||
- GuruRMM work → `projects/msp-tools/guru-rmm/` (git submodule tracking the **active** `azcomputerguru/gururmm` repo; the pinned commit normally lags `main` — that's expected, not "stale"). Empty on a fresh clone until `git submodule update --init`; `/sync` now does this automatically.
|
||||
- GuruRMM session logs → root `session-logs/` (NOT the submodule)
|
||||
- Client work → `clients/[client-name]/`
|
||||
- Session logs → project/client `session-logs/` subfolder; general work → root `session-logs/`
|
||||
- Full guide: `.claude/FILE_PLACEMENT_GUIDE.md`
|
||||
|
||||
---
|
||||
|
||||
## Local AI (Ollama)
|
||||
|
||||
Tier 0 — **Ollama is the documentation and classification engine.** Route prose, summaries, and classification through it; Claude reviews before writing or posting.
|
||||
|
||||
**Models:** `qwen3.6:latest` (structured: JSON, classification), `qwen3:8b` / `qwen3:14b` (prose), `codestral:22b` (code suggestions).
|
||||
|
||||
**Configuration:** All machine-specific config (endpoint, fallback, prose_model, python command, platform, architecture) lives in `.claude/identity.json`, populated by `.claude/scripts/migrate-identity.sh`. Scripts read `.ollama.endpoint` directly — no curl probing.
|
||||
|
||||
**Reference:** `.claude/OLLAMA.md` for full model usage + routing patterns.
|
||||
|
||||
### GrepAI (Semantic Code Search)
|
||||
|
||||
**Recall hierarchy — wiki first, GrepAI second.** GrepAI is NOT the first stop for context.
|
||||
The synthesized **wiki** (`wiki/`, 57 curated client/project/system articles) is the truth layer
|
||||
for a *known entity* — check it first (it is cheaper and already distilled). Go to GrepAI when the
|
||||
wiki can't answer:
|
||||
|
||||
1. **Code** — `grepai_search` / `grepai_trace_callers` / `grepai_trace_callees` over the Rust+TS
|
||||
corpus (~8k files). The wiki has zero code awareness; this is GrepAI's irreplaceable value for
|
||||
GuruRMM/GuruConnect dev (call-graph tracing, "where is Z implemented").
|
||||
2. **Discovery** — you don't know the entity name, or no wiki article exists yet (a new
|
||||
client/system not yet compiled).
|
||||
3. **Sub-synthesis detail** — a fact that was in a raw session log but didn't make the wiki's
|
||||
summary cut.
|
||||
|
||||
Order of recall: **wiki (known entity) -> GrepAI (code / discovery / un-compiled detail) -> raw
|
||||
file reads.** Do NOT GrepAI something the wiki already answers — that's the redundant overlap.
|
||||
|
||||
- **MCP tools:** `grepai_search` (primary), `grepai_trace_callers`, `grepai_trace_callees`
|
||||
- **Agent:** `deep-explore` (for multi-hop CODE exploration)
|
||||
- **CLI:** `$CLAUDETOOLS_ROOT/grepai search "query" --json -c -n 5`
|
||||
- **Watcher:** runs as scheduled task "GrepAI Watcher - claudetools" (auto-starts on login, keeps index current)
|
||||
|
||||
---
|
||||
|
||||
## Memory (Shared Across Machines)
|
||||
|
||||
Stored in-repo at `.claude/memory/` — syncs via Gitea to all workstations.
|
||||
Index: `.claude/memory/MEMORY.md`
|
||||
|
||||
**IMPORTANT:** Always write to `.claude/memory/` (repo-relative), NOT `~/.claude/projects/*/memory/`.
|
||||
|
||||
---
|
||||
|
||||
## Reference (read on-demand)
|
||||
|
||||
- **Fleet machine specs + onboarding checklist:** `.claude/machines/` (per-host `<hostname>.md`, plus `LINUX_PC_ONBOARDING.md`)
|
||||
- **Project structure, endpoints, workflows:** `.claude/REFERENCE.md`
|
||||
- **Agent definitions:** `.claude/agents/*.md`
|
||||
- **MCP servers:** `MCP_SERVERS.md`
|
||||
- **Coding standards:** `.claude/CODING_GUIDELINES.md`
|
||||
- **Ollama connection + examples:** `.claude/OLLAMA.md`
|
||||
- **PROJECT_STATE locking protocol:** `.claude/PROJECT_STATE_PROTOCOL.md`
|
||||
- **Temp directory graduation workflow:** `.claude/TEMP_GRADUATION.md`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-05-29
|
||||
@@ -65,9 +65,12 @@ powershell.exe -Command '$x = 5; Write-Host $x'
|
||||
|
||||
---
|
||||
|
||||
## Context Lookup — GrepAI First
|
||||
## Context Lookup — search before reading (wiki first for known entities)
|
||||
|
||||
Before reading any file for context, search with GrepAI or Grep. Only open a file when you need its full content for editing or line-by-line review.
|
||||
For a **known entity's facts** (a specific client/project/system), check the **wiki** first — it is
|
||||
the synthesized truth layer. For **code and discovery**, search with GrepAI or Grep before reading
|
||||
any file; only open a file when you need its full content for editing or line-by-line review. Full
|
||||
rule: `.claude/standards/context-lookup/grepai-first.md`.
|
||||
|
||||
| Goal | Tool |
|
||||
|------|------|
|
||||
|
||||
@@ -56,6 +56,17 @@ You are the Gitea Agent - the sole custodian of version control for all ClaudeTo
|
||||
**Authentication:** SSH key (C:\Users\MikeSwanson\.ssh\id_ed25519)
|
||||
**Local Git:** git.exe (Windows Git)
|
||||
|
||||
### Non-interactive auth (IMPORTANT)
|
||||
Mike's hard requirement: git must NEVER sit at an interactive credential/password prompt. That is his actual objection to Git for Windows — its Git Credential Manager (`credential.helper = manager`) pops a prompt and silently hangs any automation/background push. This repo (`D:\ClaudeTools`) is configured to authenticate silently instead: repo-local `credential.helper = store`, primed with the `azcomputerguru` Gitea API token in `~/.git-credentials`, scoped to the internal host `172.16.3.20:3000`. So a plain `git push origin main` / `git fetch` just works with no prompt. The global GCM default is left untouched for other repos.
|
||||
|
||||
Rules when running git here:
|
||||
- Run git from the **PowerShell tool** using native `git.exe`; quote Windows paths as-is.
|
||||
- ALWAYS set `GIT_TERMINAL_PROMPT=0` (PowerShell: `$env:GIT_TERMINAL_PROMPT='0'`) so a credential failure errors immediately instead of hanging on a hidden prompt — a hang is fatal for background agents.
|
||||
- If the stored credential is ever missing, get the token from vault `services/gitea.sops.yaml` field `api-token` (username `azcomputerguru`) and either re-append the `store` line to `~/.git-credentials` or push once to `http://azcomputerguru:<token>@172.16.3.20:3000/azcomputerguru/claudetools.git`.
|
||||
- Note: git writes progress (including "Everything up-to-date") to stderr; under PowerShell 5.1 that surfaces as a `NativeCommandError` even on success — trust `$LASTEXITCODE`/`EXIT=0`, not the red text.
|
||||
- System OpenSSH (not Git's bundled SSH) remains the rule for any SSH-based remote.
|
||||
See memory: `feedback_git_noninteractive_auth`.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
### System Repository
|
||||
|
||||
136
.claude/bootstrap/RESTORE.md
Normal file
136
.claude/bootstrap/RESTORE.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# ClaudeTools Windows Bootstrap & Recovery Runbook
|
||||
|
||||
Rebuild this workstation (GURU-5070, Lenovo Legion Pro 7 16IAX10H) after a clean
|
||||
Windows reset. Everything here is driven by two scripts in this folder:
|
||||
|
||||
- `windows-bootstrap.ps1` — installs tools, restores secrets, clones repos, wires tasks
|
||||
- `restore-secrets.ps1` — copies secrets/identity from the recovery bundle back into place
|
||||
|
||||
The recovery bundle lives on the removable drives:
|
||||
|
||||
| Drive | Label | Holds |
|
||||
|-------|---------|-------|
|
||||
| **E:** | (FAT32) | `claudetools-recovery\` — secrets + identity + manifests (redundant copy) |
|
||||
| **F:** | Ventoy | `claudetools-recovery\` — same bundle **plus** `data\` (large client data) |
|
||||
|
||||
> F: is also a bootable rescue stick (SystemRescue, Boot Repair) — keep it; it can
|
||||
> help fix the machine. The bundle lives in `F:\claudetools-recovery\`, Ventoy is untouched.
|
||||
|
||||
---
|
||||
|
||||
## What's in the bundle (and why it can't just be re-cloned)
|
||||
|
||||
`claudetools-recovery\`
|
||||
- `secrets\`
|
||||
- `sops-age\keys.txt` — **THE most critical file.** The SOPS age private key. Without
|
||||
it the entire vault (`D:\vault`) is permanently undecryptable. Not stored in any repo.
|
||||
- `ssh\` — `id_ed25519` (+pub), `pst-cc-ucg` (+pub), `config`, `known_hosts`
|
||||
- `claude\` — `.claude.json`, `.credentials.json` (Claude Code login), settings, keybindings, statusline
|
||||
- `grok\` — `auth.json`, `config.toml`, `agent_id`
|
||||
- `gemini\` — `oauth_creds.json`, `google_accounts.json`, settings, installation_id
|
||||
- `git\.gitconfig`, `powershell\Microsoft.PowerShell_profile.ps1`
|
||||
- `identity\` — repo-local gitignored files: `identity.json`, `settings.local.json`,
|
||||
`current-mode`, `coord-broadcasts-seen`, `mcp.json`, `.claude/state\`, ticktick tokens, dataforth oauth
|
||||
- `config\` — Windows Terminal settings, fleet `hosts` file, quote-wizard `.env.production`
|
||||
- `manifests\` — `installed-tools.txt`, `ollama-models.txt`, `git-global-config.txt`,
|
||||
`repos.txt`, `user-environment.reg` / `.txt` (incl. `OLLAMA_MODELS`/`OLLAMA_HOST`/`PROTOC`), `scheduled-tasks\*.xml`
|
||||
- `at-risk-work\` — local-only WIP rescued from the submodules (not on any remote):
|
||||
guru-rmm stashes as `.patch` files + guru-connect `tmp-spec018.diff`. The bootstrap
|
||||
re-applies these automatically in Phase 6 (`restore-at-risk-work.ps1`) — the guru-rmm
|
||||
ones are put back **as stashes** (`git stash list`), the guru-connect diff is dropped
|
||||
back as its untracked working file. See `RESTORE-at-risk-work.txt` for manual steps.
|
||||
- `data\` (F: only) — large non-Gitea client/project data, repo-relative paths
|
||||
|
||||
Everything else (all tracked code, skills, commands, docs, session logs, wiki) comes
|
||||
back from Gitea on clone — no need to back it up.
|
||||
|
||||
---
|
||||
|
||||
## Fast path (one shot)
|
||||
|
||||
From an **elevated PowerShell**, with E: or F: plugged in:
|
||||
|
||||
```powershell
|
||||
# copy the script off the drive first (so it survives a re-clone)
|
||||
Copy-Item F:\claudetools-recovery\bootstrap\windows-bootstrap.ps1 $env:TEMP\boot.ps1
|
||||
& $env:TEMP\.. # or just run directly:
|
||||
F:\claudetools-recovery\bootstrap\windows-bootstrap.ps1 -SkipModels
|
||||
```
|
||||
|
||||
Run it from an **elevated** shell so Phase 0 can rename the machine to `GURU-5070`
|
||||
(read from the bundle's identity.json; override with `-Hostname <name>`). The rename
|
||||
needs a **reboot** to take effect — the script reminds you at the end. Re-run after the
|
||||
reboot to finish any phases that depend on the hostname.
|
||||
|
||||
`-SkipModels` defers the ~50 GB Ollama downloads. Drop it (or run Phase 8 later) when
|
||||
you want them. Add `-RestoreData` to also pull back the large client data from `F:\...\data`.
|
||||
|
||||
The script is **idempotent** — safe to re-run; it skips anything already done. To run
|
||||
just part of it: `-OnlyPhases "1,2,3"`.
|
||||
|
||||
---
|
||||
|
||||
## Manual path (if you'd rather do it by hand)
|
||||
|
||||
0. **Set the hostname** (elevated): `Rename-Computer -NewName GURU-5070 -Restart`. Do this
|
||||
first so scheduled tasks / coord session IDs line up after the reboot.
|
||||
1. **Install App Installer** (winget) from the Microsoft Store if missing.
|
||||
2. **Core tools** (winget ids):
|
||||
`Git.Git`, `OpenJS.NodeJS.LTS`, `Python.Python.3.14`, `Rustlang.Rustup`,
|
||||
`Microsoft.VisualStudioCode`, `Ollama.Ollama`, `jqlang.jq`,
|
||||
`SecretsOPerationS.SOPS`, `FiloSottile.age`, `GitHub.cli`, `AgileBits.1Password.CLI`,
|
||||
`Microsoft.DotNet.SDK.8`, `Google.Protobuf`, `oschwartz10612.Poppler`, `Tailscale.Tailscale`
|
||||
Then `dotnet tool install --global wix` (MSI builds).
|
||||
Set env: `OLLAMA_MODELS=D:\OllamaModels`, `OLLAMA_HOST=0.0.0.0:11434`, `PROTOC=<protoc.exe>`.
|
||||
3. **AI CLIs:**
|
||||
- Claude: `irm https://claude.ai/install.ps1 | iex` → `~/.local/bin/claude.exe`
|
||||
- Gemini: `npm install -g @google/gemini-cli`
|
||||
- Grok: `bash -c "curl -fsSL https://x.ai/cli/install.sh | bash"` (Git Bash)
|
||||
4. **Restore home secrets:** `F:\claudetools-recovery\bootstrap\restore-secrets.ps1 -Group home`
|
||||
5. **Clone repos:**
|
||||
```
|
||||
git clone https://git.azcomputerguru.com/azcomputerguru/claudetools.git D:\claudetools
|
||||
cd D:\claudetools; git submodule update --init --recursive
|
||||
git clone https://git.azcomputerguru.com/azcomputerguru/vault.git D:\vault
|
||||
```
|
||||
(On-network you can use `http://172.16.3.20:3000/...` to bypass the SSL-renewal blips.)
|
||||
6. **Restore identity:** `restore-secrets.ps1 -Group repo`
|
||||
7. **Ollama models (proper set for this 12 GB-VRAM laptop):**
|
||||
`ollama pull nomic-embed-text:latest` (GrepAI embeddings) and `ollama pull qwen3:8b` (prose_model).
|
||||
Models live on `D:\OllamaModels` (47.8 GB) — **if D: survived the reset they're already there, skip this.**
|
||||
Heavy extras (`qwen3:14b`, `codestral:22b`, `qwen3.6:latest`) are opt-in only; they over-saturate 12 GB VRAM.
|
||||
8. **Scheduled tasks:** import each XML in `manifests\scheduled-tasks\` via
|
||||
`Register-ScheduledTask -Xml (Get-Content x.xml -Raw) -TaskName "..."`.
|
||||
9. **Verify:** `D:\claudetools\.claude\scripts\onboarding-diagnostic.ps1`, then `/self-check` in Claude Code.
|
||||
|
||||
---
|
||||
|
||||
## Post-install: things that need an interactive login
|
||||
|
||||
Auth tokens are backed up, but some expire. If a tool says it's unauthenticated:
|
||||
|
||||
- **Claude Code:** run `claude`, then `/login` (browser).
|
||||
- **GitHub CLI:** `gh auth login`
|
||||
- **1Password:** `op signin`
|
||||
- **Gemini:** launch `gemini`, complete the Google OAuth browser flow.
|
||||
- **Grok:** `grok login` (tokens expire after 7 days).
|
||||
- **Gitea git push:** uses the Windows Credential Manager (`credential.helper=manager`).
|
||||
First push prompts for the shared `azcomputerguru` account. **Do NOT** bake the password
|
||||
into the remote URL (the old `D:\work\gururmm` clone did — reset it to a clean URL).
|
||||
|
||||
## Verify the vault decrypts (proves the age key restored correctly)
|
||||
|
||||
```
|
||||
bash D:/claudetools/.claude/scripts/vault.sh list
|
||||
bash D:/claudetools/.claude/scripts/vault.sh get-field projects/claudetools/database.sops.yaml credentials.password
|
||||
```
|
||||
|
||||
If that returns the password, recovery succeeded. If it errors about decryption, the
|
||||
age key at `%APPDATA%\sops\age\keys.txt` and `~/.config/sops/age/keys.txt` is missing/wrong.
|
||||
|
||||
---
|
||||
|
||||
## Refreshing this bundle later
|
||||
|
||||
Re-run the backup any time (it's just file copies):
|
||||
`D:\claudetools\.claude\bootstrap\backup-to-bundle.ps1` (writes to E: and F:).
|
||||
169
.claude/bootstrap/backup-to-bundle.ps1
Normal file
169
.claude/bootstrap/backup-to-bundle.ps1
Normal file
@@ -0,0 +1,169 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Back up ClaudeTools secrets + identity (and optionally large client data) to a
|
||||
recovery bundle on a removable drive. The inverse of restore-secrets.ps1.
|
||||
|
||||
.DESCRIPTION
|
||||
Captures everything that will NOT come back from a `git clone`:
|
||||
- out-of-repo secrets under the user profile (age key, ssh, tool auth, git, PS profile)
|
||||
- repo-local gitignored identity files
|
||||
- environment manifests (installed tools, ollama models, scheduled-task XML, vscode ext)
|
||||
- (optional) large gitignored client/project data clusters
|
||||
|
||||
Safe to re-run; it refreshes the bundle in place.
|
||||
|
||||
.PARAMETER Drives Target drive roots. Default 'E:','F:' (writes the small bundle to both).
|
||||
.PARAMETER IncludeData Also copy the large client-data clusters (only to the FIRST drive with room; exFAT recommended).
|
||||
.PARAMETER ClaudeToolsRoot Default D:\claudetools.
|
||||
|
||||
.EXAMPLE
|
||||
.\backup-to-bundle.ps1 # secrets+identity+manifests to E: and F:
|
||||
.\backup-to-bundle.ps1 -IncludeData # also large data (to F:)
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string[]]$Drives = @('E:','F:'),
|
||||
[switch]$IncludeData,
|
||||
[string]$ClaudeToolsRoot = 'D:\claudetools',
|
||||
[string]$DataDrive = 'F:'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$u = $env:USERPROFILE
|
||||
|
||||
# Decode native (git) stdout as UTF-8 so captured patch text is not mangled, and give
|
||||
# us a UTF-8 (no BOM) encoding for writing patches `git apply` can actually parse.
|
||||
try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch {}
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
|
||||
function Save($src,$dst){
|
||||
if (Test-Path -LiteralPath $src) {
|
||||
$p = Split-Path $dst -Parent; if (-not (Test-Path $p)) { New-Item -ItemType Directory -Force -Path $p | Out-Null }
|
||||
Copy-Item -LiteralPath $src -Destination $dst -Force; Write-Host "[OK] $src"
|
||||
} else { Write-Host "[MISS] $src" }
|
||||
}
|
||||
|
||||
# Build the bundle once under the first available target, then mirror to the rest.
|
||||
$primary = $Drives | Where-Object { Test-Path "$_\" } | Select-Object -First 1
|
||||
if (-not $primary) { throw "None of the target drives are accessible: $($Drives -join ', ')" }
|
||||
$root = "$primary\claudetools-recovery"
|
||||
Write-Host "=== building bundle at $root ===" -ForegroundColor Cyan
|
||||
foreach ($d in 'secrets\sops-age','secrets\ssh','secrets\claude','secrets\grok','secrets\gemini','secrets\git','secrets\powershell','identity\state','manifests\scheduled-tasks','bootstrap') {
|
||||
New-Item -ItemType Directory -Force -Path "$root\$d" | Out-Null
|
||||
}
|
||||
|
||||
# --- secrets ---
|
||||
Save "$u\.config\sops\age\keys.txt" "$root\secrets\sops-age\keys.txt"
|
||||
if (Test-Path "$u\.ssh") { Copy-Item "$u\.ssh\*" "$root\secrets\ssh\" -Force; Write-Host "[OK] ~/.ssh/*" }
|
||||
Save "$u\.claude.json" "$root\secrets\claude\.claude.json"
|
||||
Save "$u\.claude\.credentials.json" "$root\secrets\claude\.credentials.json"
|
||||
Save "$u\.claude\settings.json" "$root\secrets\claude\settings.json"
|
||||
Save "$u\.claude\keybindings.json" "$root\secrets\claude\keybindings.json"
|
||||
Save "$u\.claude\statusline-command.sh" "$root\secrets\claude\statusline-command.sh"
|
||||
Save "$u\.grok\auth.json" "$root\secrets\grok\auth.json"
|
||||
Save "$u\.grok\config.toml" "$root\secrets\grok\config.toml"
|
||||
Save "$u\.grok\agent_id" "$root\secrets\grok\agent_id"
|
||||
Save "$u\.gemini\oauth_creds.json" "$root\secrets\gemini\oauth_creds.json"
|
||||
Save "$u\.gemini\google_accounts.json" "$root\secrets\gemini\google_accounts.json"
|
||||
Save "$u\.gemini\settings.json" "$root\secrets\gemini\settings.json"
|
||||
Save "$u\.gemini\installation_id" "$root\secrets\gemini\installation_id"
|
||||
Save "$u\.gitconfig" "$root\secrets\git\.gitconfig"
|
||||
# user-global Claude commands + plugins (not in repo)
|
||||
if (Test-Path "$u\.claude\commands") { New-Item -ItemType Directory -Force -Path "$root\secrets\claude-global\commands" | Out-Null; robocopy "$u\.claude\commands" "$root\secrets\claude-global\commands" /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null; Write-Host "[OK] ~/.claude/commands" }
|
||||
if (Test-Path "$u\.claude\plugins") { New-Item -ItemType Directory -Force -Path "$root\secrets\claude-global\plugins" | Out-Null; robocopy "$u\.claude\plugins" "$root\secrets\claude-global\plugins" /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null; Write-Host "[OK] ~/.claude/plugins" }
|
||||
Save $PROFILE "$root\secrets\powershell\Microsoft.PowerShell_profile.ps1"
|
||||
|
||||
# --- repo-local identity ---
|
||||
Save "$ClaudeToolsRoot\.claude\identity.json" "$root\identity\identity.json"
|
||||
Save "$ClaudeToolsRoot\.claude\settings.local.json" "$root\identity\settings.local.json"
|
||||
Save "$ClaudeToolsRoot\.claude\current-mode" "$root\identity\current-mode"
|
||||
Save "$ClaudeToolsRoot\.claude\coord-broadcasts-seen" "$root\identity\coord-broadcasts-seen"
|
||||
Save "$ClaudeToolsRoot\.mcp.json" "$root\identity\mcp.json"
|
||||
Save "$ClaudeToolsRoot\mcp-servers\ticktick\.tokens.json" "$root\identity\ticktick-tokens.json"
|
||||
Save "$ClaudeToolsRoot\clients\dataforth\Oauth.txt" "$root\identity\dataforth-oauth.txt"
|
||||
if (Test-Path "$ClaudeToolsRoot\.claude\state") { Copy-Item "$ClaudeToolsRoot\.claude\state\*" "$root\identity\state\" -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
# --- bootstrap scripts (so the drive is self-contained) ---
|
||||
Copy-Item "$ClaudeToolsRoot\.claude\bootstrap\*.ps1" "$root\bootstrap\" -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item "$ClaudeToolsRoot\.claude\bootstrap\RESTORE.md" "$root\bootstrap\" -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# --- at-risk local WIP: stashes + untracked diffs that are on NO remote ---
|
||||
# Written as UTF-8 (no BOM, LF) so restore-at-risk-work.ps1 / `git apply` can parse them.
|
||||
# (Earlier ad-hoc captures used PowerShell `>` redirection = UTF-16, which git apply
|
||||
# rejects with "No valid patches in input" - hence the explicit byte-level write here.)
|
||||
$awRoot = "$root\at-risk-work"
|
||||
function Save-RepoStashes($repo,$label){
|
||||
if (-not (Test-Path "$repo\.git")) { return }
|
||||
$marks = @(& git -C $repo stash list --format='%gd' 2>$null)
|
||||
if (-not $marks) { return }
|
||||
$dir = "$awRoot\$label"; New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$base = (& git -C $repo rev-parse HEAD 2>$null)
|
||||
[System.IO.File]::WriteAllText("$dir\BASE-COMMIT.txt", "$base`n", $Utf8NoBom)
|
||||
for ($i=0; $i -lt $marks.Count; $i++) {
|
||||
$files = @(& git -C $repo stash show --name-only "stash@{$i}" 2>$null)
|
||||
$slug = if ($files.Count) { ([IO.Path]::GetFileNameWithoutExtension($files[0])) -replace '[^\w\-]','_' } else { "stash$i" }
|
||||
$lines = @(& git -C $repo --no-pager stash show -p "stash@{$i}" 2>$null)
|
||||
[System.IO.File]::WriteAllText("$dir\stash$i-$slug.patch", (($lines -join "`n") + "`n"), $Utf8NoBom)
|
||||
Write-Host "[OK] at-risk stash: $label stash@{$i} -> stash$i-$slug.patch"
|
||||
}
|
||||
}
|
||||
Save-RepoStashes "$ClaudeToolsRoot\projects\msp-tools\guru-rmm" 'guru-rmm'
|
||||
Save-RepoStashes "$ClaudeToolsRoot\projects\msp-tools\guru-connect" 'guru-connect'
|
||||
# untracked working diffs (e.g. tmp-*.diff) that aren't committed anywhere
|
||||
$gcRepo = "$ClaudeToolsRoot\projects\msp-tools\guru-connect"
|
||||
if (Test-Path $gcRepo) {
|
||||
Get-ChildItem $gcRepo -Filter 'tmp-*.diff' -File -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$dir = "$awRoot\guru-connect"; New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
Copy-Item $_.FullName "$dir\$($_.Name)" -Force; Write-Host "[OK] at-risk untracked diff: guru-connect\$($_.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
# --- manifests ---
|
||||
$m = "$root\manifests"
|
||||
$tools = 'node','npm','claude','gemini','grok','ollama','py','git','gh','jq','sops','age','cargo','rustc','code','op'
|
||||
($tools | ForEach-Object { $c = Get-Command $_ -ErrorAction SilentlyContinue; if ($c) { $v = try { (& $_ --version 2>$null | Select-Object -First 1) } catch {''}; "{0,-10} {1,-55} {2}" -f $_,$c.Source,$v } else { "{0,-10} NOT INSTALLED" -f $_ } }) | Out-File "$m\installed-tools.txt" -Encoding utf8
|
||||
ollama list 2>$null | Out-File "$m\ollama-models.txt" -Encoding utf8
|
||||
git config --global --list | Out-File "$m\git-global-config.txt" -Encoding utf8
|
||||
$ext = & code --list-extensions 2>$null; if ($ext) { $ext | Out-File "$m\vscode-extensions.txt" -Encoding utf8 }
|
||||
foreach ($tn in "GrepAI Watcher - claudetools","ClaudeTools - Orphaned Session Detector","ClaudeTools - KSTEEN SmartBadge Daily") {
|
||||
$safe = ($tn -replace '[^\w\-]','_')
|
||||
try { Export-ScheduledTask -TaskName $tn 2>$null | Out-File "$m\scheduled-tasks\$safe.xml" -Encoding utf8 } catch {}
|
||||
}
|
||||
# user environment vars (.reg restorable + readable)
|
||||
reg export "HKCU\Environment" "$m\user-environment.reg" /y 2>$null | Out-Null
|
||||
(Get-Item 'HKCU:\Environment' | Select-Object -ExpandProperty Property | ForEach-Object { "{0}={1}" -f $_, (Get-ItemProperty 'HKCU:\Environment' -Name $_).$_ }) | Out-File "$m\user-environment.txt" -Encoding utf8
|
||||
|
||||
# --- machine config (Windows Terminal, hosts, repo-local real .env files) ---
|
||||
New-Item -ItemType Directory -Force -Path "$root\config" | Out-Null
|
||||
$wt = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json"
|
||||
if (Test-Path $wt) { Save $wt "$root\config\windows-terminal-settings.json" }
|
||||
Save "$env:WINDIR\System32\drivers\etc\hosts" "$root\config\hosts"
|
||||
Save "$ClaudeToolsRoot\projects\msp-tools\quote-wizard\frontend\.env.production" "$root\config\quote-wizard.frontend.env.production"
|
||||
|
||||
# --- large data (optional) ---
|
||||
if ($IncludeData) {
|
||||
$base = "$DataDrive\claudetools-recovery\data"
|
||||
$xd = @('node_modules','.venv','venv','__pycache__','target','.grepai','.pytest_cache','dist','build')
|
||||
$xf = @('Thumbs.db','desktop.ini','*.pyc','*.mp3') # radio-show MP3s live on IX Web Hosting - not backed up here
|
||||
$clusters = @(
|
||||
'clients\valleywide\app-modernization\source-analysis',
|
||||
'clients\grabb-durando\ai-demand-review',
|
||||
'projects\dataforth-dos\datasheet-pipeline',
|
||||
'projects\dataforth-dos\dfwds-research',
|
||||
'projects\radio-show\audio-processor'
|
||||
)
|
||||
Write-Host "=== copying large data to $base ===" -ForegroundColor Cyan
|
||||
foreach ($c in $clusters) {
|
||||
if (Test-Path "$ClaudeToolsRoot\$c") { robocopy "$ClaudeToolsRoot\$c" "$base\$c" /E /R:1 /W:1 /XD $xd /XF $xf /NFL /NDL /NP | Out-Null; Write-Host "[OK] $c" }
|
||||
}
|
||||
}
|
||||
|
||||
# --- mirror small bundle to the other drives ---
|
||||
foreach ($d in $Drives) {
|
||||
if ($d -eq $primary) { continue }
|
||||
if (Test-Path "$d\") {
|
||||
Write-Host "=== mirroring bundle -> $d\claudetools-recovery ===" -ForegroundColor Cyan
|
||||
robocopy $root "$d\claudetools-recovery" /E /R:1 /W:1 /XD data /NFL /NDL /NP | Out-Null
|
||||
Write-Host "[OK] mirrored to $d"
|
||||
}
|
||||
}
|
||||
Write-Host "`n[DONE] backup-to-bundle.ps1" -ForegroundColor Green
|
||||
113
.claude/bootstrap/restore-at-risk-work.ps1
Normal file
113
.claude/bootstrap/restore-at-risk-work.ps1
Normal file
@@ -0,0 +1,113 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restore local-only WIP (stashes + untracked diffs) that was rescued into the
|
||||
recovery bundle's at-risk-work\ folder. Run AFTER the repos + submodules are cloned.
|
||||
|
||||
.DESCRIPTION
|
||||
guru-rmm : each stashN-*.patch is applied to the working tree and then re-stashed,
|
||||
faithfully recreating the original `git stash` entries. Patches are
|
||||
processed highest-N-first so stash0 ends up on top (stash@{0}), matching
|
||||
the original LIFO order. The working tree is left CLEAN (changes live in
|
||||
the stash, exactly as before).
|
||||
guru-connect : tmp-spec018.diff was an UNTRACKED working file, so it is copied back
|
||||
into the repo as-is (not applied). Apply it yourself if/when you want it.
|
||||
|
||||
Non-destructive and re-runnable. If a patch won't apply cleanly (submodule moved on),
|
||||
it is reported and the .patch file is left in place for manual `git apply --3way`.
|
||||
|
||||
ROBUSTNESS NOTES (why this is not just `git apply <file>`):
|
||||
* Patch files may have been written by PowerShell redirection (UTF-16 LE/BE w/ BOM).
|
||||
`git apply` only understands UTF-8/ASCII and otherwise reports
|
||||
"No valid patches in input". Get-Utf8PatchPath normalizes any encoding to a
|
||||
UTF-8 (no BOM) temp copy before applying.
|
||||
* git writes progress/errors to stderr; capturing that with `2>&1` while
|
||||
$ErrorActionPreference='Stop' turns it into a *terminating* error (PS 5.1
|
||||
NativeCommandError) that aborts the whole bootstrap. Invoke-Git captures
|
||||
output without that trap and returns the real exit code.
|
||||
* If the submodule still has stashes, the WIP almost certainly survived the reset.
|
||||
Re-applying would create DUPLICATE stashes, so we skip and report instead.
|
||||
|
||||
.PARAMETER BundlePath Recovery bundle root (auto-detect F:\ then E:\).
|
||||
.PARAMETER ClaudeToolsRoot Default D:\claudetools.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([string]$BundlePath,[string]$ClaudeToolsRoot='D:\claudetools')
|
||||
$ErrorActionPreference='Stop'
|
||||
|
||||
# Read a patch regardless of encoding (UTF-16 LE/BE +/- BOM, UTF-8 +/- BOM) and return
|
||||
# the path to a normalized UTF-8 (no BOM) temp copy that `git apply` can parse.
|
||||
function Get-Utf8PatchPath($path){
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { $text = [System.Text.Encoding]::Unicode.GetString($bytes,2,$bytes.Length-2) }
|
||||
elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { $text = [System.Text.Encoding]::BigEndianUnicode.GetString($bytes,2,$bytes.Length-2) }
|
||||
elseif ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $text = [System.Text.Encoding]::UTF8.GetString($bytes,3,$bytes.Length-3) }
|
||||
else {
|
||||
# No BOM: detect UTF-16 LE without BOM by counting interleaved NUL bytes in the head.
|
||||
$nul = 0; $n = [Math]::Min(64,$bytes.Length)
|
||||
for ($i=0; $i -lt $n; $i++) { if ($bytes[$i] -eq 0) { $nul++ } }
|
||||
if ($nul -gt 8) { $text = [System.Text.Encoding]::Unicode.GetString($bytes) }
|
||||
else { $text = [System.Text.Encoding]::UTF8.GetString($bytes) }
|
||||
}
|
||||
$text = $text -replace "`r`n","`n" # normalize to LF so git apply is happy
|
||||
$tmp = [System.IO.Path]::GetTempFileName()
|
||||
[System.IO.File]::WriteAllText($tmp, $text, (New-Object System.Text.UTF8Encoding($false)))
|
||||
return $tmp
|
||||
}
|
||||
|
||||
# Run git without letting native stderr (under $ErrorActionPreference='Stop') become a
|
||||
# terminating error. Returns [pscustomobject]@{ Code; Output }.
|
||||
function Invoke-Git([string[]]$GitArgs){
|
||||
$old = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
||||
try { $out = (& git @GitArgs 2>&1 | Out-String); $code = $LASTEXITCODE }
|
||||
finally { $ErrorActionPreference = $old }
|
||||
[pscustomobject]@{ Code = $code; Output = ($out).Trim() }
|
||||
}
|
||||
|
||||
if (-not $BundlePath) { foreach ($d in 'F:','E:','D:') { if (Test-Path "$d\claudetools-recovery\at-risk-work") { $BundlePath="$d\claudetools-recovery"; break } } }
|
||||
$aw = "$BundlePath\at-risk-work"
|
||||
if (-not $BundlePath -or -not (Test-Path $aw)) { Write-Host "[INFO] no at-risk-work folder found in bundle - nothing to restore"; return }
|
||||
Write-Host "[INFO] restoring at-risk WIP from $aw" -ForegroundColor Cyan
|
||||
|
||||
function Have-Git($repo){ Test-Path "$repo\.git" }
|
||||
|
||||
# ---- guru-rmm stashes ----
|
||||
$rmm = "$ClaudeToolsRoot\projects\msp-tools\guru-rmm"
|
||||
if ((Test-Path "$aw\guru-rmm") -and (Have-Git $rmm)) {
|
||||
$existing = (Invoke-Git @('-C',$rmm,'stash','list')).Output
|
||||
if ($existing) {
|
||||
Write-Host "[SKIP] guru-rmm already has stashes (local WIP survived the reset) - not re-applying to avoid duplicates:" -ForegroundColor Yellow
|
||||
Write-Host $existing
|
||||
Write-Host " Bundle patches remain in $aw\guru-rmm; apply by hand if you really need them." -ForegroundColor Yellow
|
||||
}
|
||||
elseif ((Invoke-Git @('-C',$rmm,'status','--porcelain')).Output) {
|
||||
Write-Host "[WARN] guru-rmm working tree is dirty; skipping auto-restore to avoid mixing changes. Apply patches in $aw\guru-rmm manually." -ForegroundColor Yellow
|
||||
} else {
|
||||
# highest N first so stash0 lands at stash@{0}
|
||||
$patches = Get-ChildItem "$aw\guru-rmm" -Filter '*.patch' | Sort-Object Name -Descending
|
||||
foreach ($p in $patches) {
|
||||
$u8 = Get-Utf8PatchPath $p.FullName
|
||||
try {
|
||||
$chk = Invoke-Git @('-C',$rmm,'apply','--check','--3way',$u8)
|
||||
if ($chk.Code -ne 0) { Write-Host "[WARN] won't apply cleanly, left for manual restore: $($p.Name) ($($chk.Output))" -ForegroundColor Yellow; continue }
|
||||
Invoke-Git @('-C',$rmm,'apply','--3way',$u8) | Out-Null
|
||||
Invoke-Git @('-C',$rmm,'stash','push','-u','-m',"restored WIP: $($p.BaseName)") | Out-Null
|
||||
Write-Host "[OK] re-stashed guru-rmm: $($p.BaseName)" -ForegroundColor Green
|
||||
} finally { Remove-Item $u8 -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
Write-Host "[INFO] guru-rmm stashes now:" -ForegroundColor Cyan
|
||||
Write-Host (Invoke-Git @('-C',$rmm,'stash','list')).Output
|
||||
}
|
||||
}
|
||||
|
||||
# ---- guru-connect untracked diff ----
|
||||
$gc = "$ClaudeToolsRoot\projects\msp-tools\guru-connect"
|
||||
$diff = "$aw\guru-connect\tmp-spec018.diff"
|
||||
if ((Test-Path $diff) -and (Test-Path $gc)) {
|
||||
if (Test-Path "$gc\tmp-spec018.diff") {
|
||||
Write-Host "[SKIP] guru-connect\tmp-spec018.diff already present in repo (survived the reset) - not overwriting." -ForegroundColor Yellow
|
||||
} else {
|
||||
Copy-Item $diff "$gc\tmp-spec018.diff" -Force
|
||||
Write-Host "[OK] guru-connect\tmp-spec018.diff restored (untracked working file - 'git apply --3way tmp-spec018.diff' to apply it)" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
Write-Host "[DONE] at-risk WIP restore" -ForegroundColor Cyan
|
||||
147
.claude/bootstrap/restore-secrets.ps1
Normal file
147
.claude/bootstrap/restore-secrets.ps1
Normal file
@@ -0,0 +1,147 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Restore ClaudeTools secrets + machine identity from a recovery bundle
|
||||
(produced by the Windows bootstrap backup) back to their real locations.
|
||||
|
||||
.DESCRIPTION
|
||||
Two restore groups:
|
||||
[home] -> out-of-repo secrets that live under the user profile
|
||||
(SOPS age key, SSH keys, Claude/grok/gemini auth, git config,
|
||||
PowerShell profile). These are needed BEFORE cloning repos.
|
||||
[repo] -> repo-local, gitignored files that go back into D:\claudetools
|
||||
(identity.json, settings.local.json, current-mode, .mcp.json,
|
||||
.claude/state, ticktick tokens, dataforth oauth). These require
|
||||
the claudetools repo to already be cloned.
|
||||
|
||||
Idempotent. Only restores files that exist in the bundle. Never overwrites a
|
||||
newer file unless -Force is given.
|
||||
|
||||
.PARAMETER BundlePath
|
||||
Path to the recovery bundle root (the folder containing 'secrets' and
|
||||
'identity'). Auto-detected from F:\ then E:\ if not supplied.
|
||||
|
||||
.PARAMETER ClaudeToolsRoot
|
||||
Where claudetools is / will be cloned. Default D:\claudetools.
|
||||
|
||||
.PARAMETER Group
|
||||
home | repo | all (default all).
|
||||
|
||||
.EXAMPLE
|
||||
.\restore-secrets.ps1 -Group home # before cloning repos
|
||||
.\restore-secrets.ps1 -Group repo # after cloning claudetools
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$BundlePath,
|
||||
[string]$ClaudeToolsRoot = 'D:\claudetools',
|
||||
[ValidateSet('home','repo','all')][string]$Group = 'all',
|
||||
[switch]$Force
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Find-Bundle {
|
||||
foreach ($d in 'F:','E:','D:') {
|
||||
$p = "$d\claudetools-recovery"
|
||||
if (Test-Path "$p\secrets") { return $p }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
if (-not $BundlePath) { $BundlePath = Find-Bundle }
|
||||
if (-not $BundlePath -or -not (Test-Path "$BundlePath\secrets")) {
|
||||
throw "Recovery bundle not found. Plug in the drive or pass -BundlePath. Looked for <drive>:\claudetools-recovery\secrets"
|
||||
}
|
||||
Write-Host "[INFO] Using recovery bundle: $BundlePath" -ForegroundColor Cyan
|
||||
|
||||
function Restore-One($src, $dst) {
|
||||
if (-not (Test-Path -LiteralPath $src)) { Write-Host "[SKIP] not in bundle: $src"; return }
|
||||
$parent = Split-Path $dst -Parent
|
||||
if ($parent -and -not (Test-Path $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
|
||||
if ((Test-Path -LiteralPath $dst) -and -not $Force) {
|
||||
Write-Host "[KEEP] exists (use -Force to overwrite): $dst" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
Copy-Item -LiteralPath $src -Destination $dst -Force
|
||||
Write-Host "[OK] $dst" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- HOME secrets
|
||||
if ($Group -in 'home','all') {
|
||||
Write-Host "`n=== Restoring home-profile secrets ===" -ForegroundColor Cyan
|
||||
$u = $env:USERPROFILE
|
||||
$s = "$BundlePath\secrets"
|
||||
|
||||
# SOPS age key (CRITICAL - vault is undecryptable without it)
|
||||
New-Item -ItemType Directory -Force -Path "$u\.config\sops\age" | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path "$env:APPDATA\sops\age" | Out-Null
|
||||
Restore-One "$s\sops-age\keys.txt" "$u\.config\sops\age\keys.txt"
|
||||
Restore-One "$s\sops-age\keys.txt" "$env:APPDATA\sops\age\keys.txt"
|
||||
|
||||
# SSH
|
||||
New-Item -ItemType Directory -Force -Path "$u\.ssh" | Out-Null
|
||||
if (Test-Path "$s\ssh") {
|
||||
Get-ChildItem "$s\ssh" -File | ForEach-Object { Restore-One $_.FullName "$u\.ssh\$($_.Name)" }
|
||||
# lock down private key perms (remove inheritance, owner-only)
|
||||
Get-ChildItem "$u\.ssh" -File | Where-Object { $_.Name -notmatch '\.pub$' -and $_.Name -ne 'known_hosts' -and $_.Name -ne 'config' } | ForEach-Object {
|
||||
icacls $_.FullName /inheritance:r /grant:r "$($env:USERNAME):(F)" 2>$null | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Claude Code auth/config
|
||||
Restore-One "$s\claude\.claude.json" "$u\.claude.json"
|
||||
Restore-One "$s\claude\.credentials.json" "$u\.claude\.credentials.json"
|
||||
Restore-One "$s\claude\settings.json" "$u\.claude\settings.json"
|
||||
Restore-One "$s\claude\keybindings.json" "$u\.claude\keybindings.json"
|
||||
Restore-One "$s\claude\statusline-command.sh" "$u\.claude\statusline-command.sh"
|
||||
|
||||
# grok
|
||||
Restore-One "$s\grok\auth.json" "$u\.grok\auth.json"
|
||||
Restore-One "$s\grok\config.toml" "$u\.grok\config.toml"
|
||||
Restore-One "$s\grok\agent_id" "$u\.grok\agent_id"
|
||||
|
||||
# gemini
|
||||
Restore-One "$s\gemini\oauth_creds.json" "$u\.gemini\oauth_creds.json"
|
||||
Restore-One "$s\gemini\google_accounts.json" "$u\.gemini\google_accounts.json"
|
||||
Restore-One "$s\gemini\settings.json" "$u\.gemini\settings.json"
|
||||
Restore-One "$s\gemini\installation_id" "$u\.gemini\installation_id"
|
||||
|
||||
# user-global Claude commands + plugins (not in the repo)
|
||||
if (Test-Path "$s\claude-global\commands") {
|
||||
New-Item -ItemType Directory -Force -Path "$u\.claude\commands" | Out-Null
|
||||
Copy-Item "$s\claude-global\commands\*" "$u\.claude\commands\" -Recurse -Force
|
||||
Write-Host "[OK] $u\.claude\commands\*" -ForegroundColor Green
|
||||
}
|
||||
if (Test-Path "$s\claude-global\plugins") {
|
||||
New-Item -ItemType Directory -Force -Path "$u\.claude\plugins" | Out-Null
|
||||
Copy-Item "$s\claude-global\plugins\*" "$u\.claude\plugins\" -Recurse -Force
|
||||
Write-Host "[OK] $u\.claude\plugins\*" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# git global config
|
||||
Restore-One "$s\git\.gitconfig" "$u\.gitconfig"
|
||||
|
||||
# PowerShell profile
|
||||
Restore-One "$s\powershell\Microsoft.PowerShell_profile.ps1" $PROFILE
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- REPO-local
|
||||
if ($Group -in 'repo','all') {
|
||||
Write-Host "`n=== Restoring repo-local identity files ===" -ForegroundColor Cyan
|
||||
if (-not (Test-Path $ClaudeToolsRoot)) {
|
||||
Write-Host "[WARN] $ClaudeToolsRoot does not exist yet. Clone the repo first, then re-run with -Group repo." -ForegroundColor Yellow
|
||||
} else {
|
||||
$i = "$BundlePath\identity"
|
||||
Restore-One "$i\identity.json" "$ClaudeToolsRoot\.claude\identity.json"
|
||||
Restore-One "$i\settings.local.json" "$ClaudeToolsRoot\.claude\settings.local.json"
|
||||
Restore-One "$i\current-mode" "$ClaudeToolsRoot\.claude\current-mode"
|
||||
Restore-One "$i\coord-broadcasts-seen" "$ClaudeToolsRoot\.claude\coord-broadcasts-seen"
|
||||
Restore-One "$i\mcp.json" "$ClaudeToolsRoot\.mcp.json"
|
||||
Restore-One "$i\ticktick-tokens.json" "$ClaudeToolsRoot\mcp-servers\ticktick\.tokens.json"
|
||||
Restore-One "$i\dataforth-oauth.txt" "$ClaudeToolsRoot\clients\dataforth\Oauth.txt"
|
||||
if (Test-Path "$i\state") {
|
||||
New-Item -ItemType Directory -Force -Path "$ClaudeToolsRoot\.claude\state" | Out-Null
|
||||
Copy-Item "$i\state\*" "$ClaudeToolsRoot\.claude\state\" -Recurse -Force
|
||||
Write-Host "[OK] $ClaudeToolsRoot\.claude\state\*" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "`n[DONE] restore-secrets.ps1 ($Group)" -ForegroundColor Cyan
|
||||
346
.claude/bootstrap/windows-bootstrap.ps1
Normal file
346
.claude/bootstrap/windows-bootstrap.ps1
Normal file
@@ -0,0 +1,346 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
ClaudeTools Windows bootstrap - rebuild a workstation after a clean OS reset.
|
||||
|
||||
.DESCRIPTION
|
||||
Installs every tool ClaudeTools needs, restores secrets + identity from the
|
||||
recovery bundle, clones the repos, wires up scheduled tasks, and verifies.
|
||||
Designed to be run top-to-bottom on a fresh Windows 11 install. Idempotent:
|
||||
re-running skips anything already present.
|
||||
|
||||
ORDER OF OPERATIONS (each phase depends on the previous):
|
||||
0. Preflight - winget, execution policy, UTF-8
|
||||
1. Core tooling - git, node, python, rust, vscode, ollama, jq, sops, age, gh, op
|
||||
2. PATH refresh - make freshly-installed tools callable this session
|
||||
3. AI CLIs - claude (native), gemini (npm), grok (git-bash installer)
|
||||
4. Restore secrets - age key, ssh, tool auth, git config, PS profile [home group]
|
||||
5. Clone repos - claudetools + vault + submodules
|
||||
6. Restore identity - identity.json, settings.local, .mcp.json, state [repo group]
|
||||
7. Python deps - pip installs for MCP servers / scripts
|
||||
8. Ollama models - pull qwen/codestral/nomic (optional, large)
|
||||
9. Scheduled tasks - GrepAI watcher, orphan detector, smartbadge
|
||||
10. Large data - restore client data from bundle (optional)
|
||||
11. Verify - onboarding diagnostic
|
||||
|
||||
.PARAMETER BundlePath
|
||||
Recovery bundle root (folder containing 'secrets'/'identity'). Auto-detect F:\ then E:\.
|
||||
|
||||
.PARAMETER SkipModels Skip the multi-GB ollama model pulls.
|
||||
.PARAMETER RestoreData Also restore the large client data from <bundle>\data.
|
||||
.PARAMETER GiteaHost Gitea base URL. Default git.azcomputerguru.com (use 172.16.3.20:3000 on-network).
|
||||
.PARAMETER OnlyPhases Comma list of phase numbers to run (e.g. "1,2,3"). Default: all.
|
||||
|
||||
.EXAMPLE
|
||||
# full rebuild, skip giant model downloads for now
|
||||
.\windows-bootstrap.ps1 -SkipModels
|
||||
|
||||
.NOTES
|
||||
Run from an elevated PowerShell for cleanest winget machine-scope installs,
|
||||
though most packages also install at user scope without admin.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$BundlePath,
|
||||
[switch]$SkipModels,
|
||||
[switch]$RestoreData,
|
||||
[string]$GiteaHost = 'https://git.azcomputerguru.com',
|
||||
[string]$ClaudeToolsRoot = 'D:\claudetools',
|
||||
[string]$VaultRoot = 'D:\vault',
|
||||
[string]$Hostname, # target computer name; default = identity.json .machine, else GURU-5070
|
||||
[string]$OnlyPhases
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
function Phase($n,$title){ if ($OnlyPhases -and ($OnlyPhases -split ',').Trim() -notcontains "$n") { return $false }; Write-Host "`n========== PHASE $n : $title ==========" -ForegroundColor Cyan; return $true }
|
||||
function Info($m){ Write-Host "[INFO] $m" }
|
||||
function Ok($m){ Write-Host "[OK] $m" -ForegroundColor Green }
|
||||
function Warn($m){ Write-Host "[WARN] $m" -ForegroundColor Yellow }
|
||||
function Have($cmd){ [bool](Get-Command $cmd -ErrorAction SilentlyContinue) }
|
||||
function Refresh-Path { $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') }
|
||||
|
||||
function Find-Bundle {
|
||||
if ($BundlePath -and (Test-Path "$BundlePath\secrets")) { return $BundlePath }
|
||||
foreach ($d in 'F:','E:','D:') { if (Test-Path "$d\claudetools-recovery\secrets") { return "$d\claudetools-recovery" } }
|
||||
return $null
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 0
|
||||
if (Phase 0 'Preflight') {
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force } catch {}
|
||||
if (-not (Have winget)) { throw "winget not found. Install 'App Installer' from the Microsoft Store, then re-run." }
|
||||
Ok "winget present: $((Get-Command winget).Source)"
|
||||
$script:Bundle = Find-Bundle
|
||||
if ($script:Bundle) { Ok "recovery bundle: $script:Bundle" } else { Warn "no recovery bundle found - secret/identity restore phases will be skipped" }
|
||||
|
||||
# Hostname - a fresh Windows install is DESKTOP-xxxxx; identity.json + scheduled tasks
|
||||
# + coord session IDs all expect the real name. Rename needs admin and a reboot to apply.
|
||||
$target = $Hostname
|
||||
if (-not $target -and $script:Bundle -and (Test-Path "$script:Bundle\identity\identity.json")) {
|
||||
try { $target = (Get-Content "$script:Bundle\identity\identity.json" -Raw | ConvertFrom-Json).machine } catch {}
|
||||
}
|
||||
if (-not $target) { $target = 'GURU-5070' }
|
||||
if ($env:COMPUTERNAME -ne $target) {
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
|
||||
if ($isAdmin) {
|
||||
try { Rename-Computer -NewName $target -Force -ErrorAction Stop; $script:RebootNeeded = $true; Ok "hostname: $env:COMPUTERNAME -> $target (takes effect after reboot)" }
|
||||
catch { Warn "rename to '$target' failed: $($_.Exception.Message)" }
|
||||
} else { Warn "hostname is '$env:COMPUTERNAME', target '$target' - run this script as Administrator to rename (or manually: Rename-Computer -NewName $target -Restart)" }
|
||||
} else { Ok "hostname already '$target'" }
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 1
|
||||
if (Phase 1 'Core tooling (winget)') {
|
||||
$pkgs = @(
|
||||
@{id='Git.Git'; cmd='git'},
|
||||
@{id='OpenJS.NodeJS.LTS'; cmd='node'},
|
||||
@{id='Python.Python.3.14'; cmd='py'},
|
||||
@{id='Rustlang.Rustup'; cmd='cargo'},
|
||||
@{id='Microsoft.VisualStudioCode'; cmd='code'},
|
||||
@{id='Ollama.Ollama'; cmd='ollama'},
|
||||
@{id='jqlang.jq'; cmd='jq'},
|
||||
@{id='SecretsOPerationS.SOPS'; cmd='sops'},
|
||||
@{id='FiloSottile.age'; cmd='age'},
|
||||
@{id='GitHub.cli'; cmd='gh'},
|
||||
@{id='AgileBits.1Password.CLI'; cmd='op'},
|
||||
@{id='Microsoft.DotNet.SDK.8'; cmd='dotnet'}, # MSI builds / wix
|
||||
@{id='Google.Protobuf'; cmd='protoc'}, # gururmm prost builds (PROTOC env)
|
||||
@{id='oschwartz10612.Poppler'; cmd='pdftoppm'}, # dataforth datasheet PDF pipeline
|
||||
@{id='Tailscale.Tailscale'; cmd='tailscale'} # fleet connectivity (100.x mesh)
|
||||
)
|
||||
foreach ($p in $pkgs) {
|
||||
if (Have $p.cmd) { Ok "$($p.cmd) already installed"; continue }
|
||||
Info "installing $($p.id) ..."
|
||||
winget install --id $p.id --exact --silent --accept-package-agreements --accept-source-agreements --disable-interactivity
|
||||
if ($LASTEXITCODE -ne 0) { Warn "winget returned $LASTEXITCODE for $($p.id) (may already be installed or need elevation)" }
|
||||
}
|
||||
Refresh-Path
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 2
|
||||
if (Phase 2 'PATH refresh') {
|
||||
Refresh-Path
|
||||
foreach ($c in 'git','node','npm','py','cargo','jq','sops','age','gh','op','ollama','code','dotnet','protoc','tailscale') {
|
||||
if (Have $c) { Ok "$c -> $((Get-Command $c).Source)" } else { Warn "$c still not on PATH (open a new shell after install)" }
|
||||
}
|
||||
# PROTOC env var for Rust prost builds (path is version-specific, so resolve it live)
|
||||
$protoc = (Get-Command protoc -ErrorAction SilentlyContinue).Source
|
||||
if ($protoc) { [Environment]::SetEnvironmentVariable('PROTOC',$protoc,'User'); $env:PROTOC=$protoc; Ok "PROTOC=$protoc" }
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 3
|
||||
if (Phase 3 'AI CLIs') {
|
||||
# Claude Code - official native installer -> %USERPROFILE%\.local\bin\claude.exe
|
||||
if (Have claude) { Ok "claude already installed" } else {
|
||||
Info "installing Claude Code (native installer)"
|
||||
try { irm https://claude.ai/install.ps1 | iex } catch { Warn "claude install failed: $_ (manual: irm https://claude.ai/install.ps1 | iex)" }
|
||||
}
|
||||
# Gemini CLI - npm global
|
||||
if (Have gemini) { Ok "gemini already installed" } else {
|
||||
Info "installing @google/gemini-cli"
|
||||
npm install -g @google/gemini-cli
|
||||
}
|
||||
# Grok CLI - xAI installer (bash; needs Git Bash from Phase 1)
|
||||
if (Have grok) { Ok "grok already installed" } else {
|
||||
$bash = 'C:\Program Files\Git\bin\bash.exe'
|
||||
if (Test-Path $bash) { Info "installing grok via $bash"; & $bash -lc "curl -fsSL https://x.ai/cli/install.sh | bash" }
|
||||
else { Warn "Git Bash not found; install Git first, then: bash -c 'curl -fsSL https://x.ai/cli/install.sh | bash'" }
|
||||
}
|
||||
Refresh-Path
|
||||
$env:Path += ";$env:USERPROFILE\.local\bin;$env:USERPROFILE\.grok\bin;$env:APPDATA\npm"
|
||||
# Persist the AI-CLI dirs to the User PATH so claude/grok/gemini stay callable in
|
||||
# every new shell (their installers don't always add these; grok especially is a
|
||||
# bare ~\.grok\bin drop that was session-only after the 2026-06-06 rebuild).
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path','User')
|
||||
foreach ($d in "$env:USERPROFILE\.local\bin", "$env:USERPROFILE\.grok\bin", "$env:APPDATA\npm") {
|
||||
if ((Test-Path $d) -and ($userPath -notmatch [regex]::Escape($d))) { $userPath = $userPath.TrimEnd(';') + ";$d" }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable('Path', $userPath, 'User')
|
||||
Ok "AI-CLI dirs persisted to User PATH"
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 4
|
||||
if (Phase 4 'Restore home secrets + machine config') {
|
||||
if ($script:Bundle) {
|
||||
& "$here\restore-secrets.ps1" -BundlePath $script:Bundle -Group home
|
||||
|
||||
# Stable machine env vars (NOT a blanket reg import - the saved PATH has stale
|
||||
# version-pinned winget paths. user-environment.reg is kept as reference only.)
|
||||
[Environment]::SetEnvironmentVariable('OLLAMA_MODELS','D:\OllamaModels','User'); $env:OLLAMA_MODELS='D:\OllamaModels'
|
||||
[Environment]::SetEnvironmentVariable('OLLAMA_HOST','0.0.0.0:11434','User'); $env:OLLAMA_HOST='0.0.0.0:11434'
|
||||
Ok "set OLLAMA_MODELS=D:\OllamaModels, OLLAMA_HOST=0.0.0.0:11434"
|
||||
|
||||
# Windows Terminal settings
|
||||
$wtDst = "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json"
|
||||
if (Test-Path "$script:Bundle\config\windows-terminal-settings.json") {
|
||||
$p = Split-Path $wtDst -Parent
|
||||
if (Test-Path $p) { Copy-Item "$script:Bundle\config\windows-terminal-settings.json" $wtDst -Force; Ok "Windows Terminal settings restored" }
|
||||
else { Warn "Windows Terminal not installed yet - restore its settings.json later from config\" }
|
||||
}
|
||||
|
||||
# hosts file (fleet Tailscale MagicDNS entries) - needs admin; merge note only
|
||||
if (Test-Path "$script:Bundle\config\hosts") {
|
||||
Warn "fleet hosts entries are in config\hosts - merge into $env:WINDIR\System32\drivers\etc\hosts as admin if Tailscale MagicDNS isn't resolving"
|
||||
}
|
||||
}
|
||||
else { Warn "no bundle - skipping. Restore the SOPS age key + SSH keys manually or the vault will not decrypt." }
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 5
|
||||
if (Phase 5 'Clone repos') {
|
||||
if (-not (Test-Path "$ClaudeToolsRoot\.git")) {
|
||||
Info "cloning claudetools -> $ClaudeToolsRoot"
|
||||
git clone "$GiteaHost/azcomputerguru/claudetools.git" $ClaudeToolsRoot
|
||||
Push-Location $ClaudeToolsRoot
|
||||
Info "initializing submodules (gururmm / guruconnect)"
|
||||
git submodule update --init --recursive
|
||||
Pop-Location
|
||||
} else { Ok "claudetools repo already present" }
|
||||
|
||||
if (-not (Test-Path "$VaultRoot\.git")) {
|
||||
Info "cloning vault -> $VaultRoot"
|
||||
git clone "$GiteaHost/azcomputerguru/vault.git" $VaultRoot
|
||||
} else { Ok "vault repo already present" }
|
||||
|
||||
# safe.directory entries (mirror the prior machine)
|
||||
foreach ($d in $ClaudeToolsRoot,$VaultRoot,"$ClaudeToolsRoot/projects/msp-tools/guru-rmm") {
|
||||
git config --global --add safe.directory ($d -replace '\\','/') 2>$null
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 6
|
||||
if (Phase 6 'Restore repo-local identity + at-risk WIP') {
|
||||
if ($script:Bundle) {
|
||||
& "$here\restore-secrets.ps1" -BundlePath $script:Bundle -Group repo -ClaudeToolsRoot $ClaudeToolsRoot
|
||||
# Recreate local-only WIP (guru-rmm stashes, guru-connect untracked diff) that
|
||||
# would otherwise have been lost - faithfully puts the stashes back as stashes.
|
||||
& "$here\restore-at-risk-work.ps1" -BundlePath $script:Bundle -ClaudeToolsRoot $ClaudeToolsRoot
|
||||
}
|
||||
else { Warn "no bundle - you must hand-create .claude/identity.json (see CLAUDE.md multi-user section)" }
|
||||
|
||||
# Non-interactive git auth (Mike's hard requirement: git must NEVER hang on a
|
||||
# Git Credential Manager password prompt). setup-git-auth.sh primes the `store`
|
||||
# credential helper from the vault Gitea token, scoped to each repo's actual remote
|
||||
# host. Needs the age key (Phase 4) + identity.json (above) + vault repo (Phase 5).
|
||||
# Idempotent + fail-silent; also runs from the SessionStart hook in settings.json.
|
||||
$ghauth = "$ClaudeToolsRoot\.claude\scripts\setup-git-auth.sh"
|
||||
$gbash = 'C:\Program Files\Git\bin\bash.exe'
|
||||
if ((Test-Path $ghauth) -and (Test-Path $gbash)) {
|
||||
Info "priming non-interactive git auth (vault token -> credential store)"
|
||||
& $gbash "$ghauth"
|
||||
Ok "git credential store primed; GIT_TERMINAL_PROMPT=0 enforced via .claude/settings.json env"
|
||||
} else { Warn "setup-git-auth.sh or Git Bash missing - prime git creds manually so pushes don't prompt" }
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 7
|
||||
if (Phase 7 'Python deps + .NET tools') {
|
||||
# WiX toolset (MSI builds, e.g. gururmm agent) - dotnet global tool
|
||||
if (Have dotnet) {
|
||||
if (dotnet tool list --global 2>$null | Select-String '\bwix\b') { Ok "wix tool already installed" }
|
||||
else { Info "installing wix dotnet tool"; dotnet tool install --global wix 2>$null }
|
||||
}
|
||||
# IMPORTANT: ClaudeTools uses TWO python interpreters on Windows and they must
|
||||
# BOTH have the deps, or pieces silently break:
|
||||
# - `py` -> Python 3.14 : vault yaml-query.py (get-field), helper/skill
|
||||
# scripts, scheduled tasks (detect_orphaned_sessions)
|
||||
# - `python` -> Python 3.12 : the interpreter `.mcp.json` launches the MCP
|
||||
# servers with (ticktick needs httpx + mcp)
|
||||
# Installing into only one leaves the other broken (the 2026-06-06 rebuild shipped
|
||||
# with ticktick MCP dead = no httpx/mcp in 3.12, and vault get-field dead = no
|
||||
# PyYAML in 3.14). De-dupe by real sys.executable so a single install isn't run twice.
|
||||
$interps = @(); $seen = @{}
|
||||
foreach ($cand in 'py','python','python3') {
|
||||
if (Have $cand) {
|
||||
$real = (& $cand -c "import sys;print(sys.executable)" 2>$null)
|
||||
if ($real -and -not $seen[$real]) { $seen[$real] = $true; $interps += $cand }
|
||||
}
|
||||
}
|
||||
if (-not $interps) { Warn "no python interpreter found - skip python deps" }
|
||||
else {
|
||||
$reqs = Get-ChildItem $ClaudeToolsRoot -Recurse -Filter 'requirements*.txt' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -notmatch '\\(node_modules|\.venv|venv|target)\\' }
|
||||
# baseline libs used by helper scripts / MCP / vault across the harness
|
||||
$baseline = @('requests','paramiko','mcp','httpx','pyyaml','websocket-client')
|
||||
foreach ($ic in $interps) {
|
||||
Info "[$ic] upgrading pip"; & $ic -m pip install --upgrade pip 2>$null
|
||||
foreach ($r in $reqs) { Info "[$ic] pip install -r $($r.Name)"; & $ic -m pip install -r $r.FullName 2>$null }
|
||||
Info "[$ic] baseline libs"; & $ic -m pip install @baseline 2>$null
|
||||
}
|
||||
Ok "python deps installed into: $($interps -join ', ') (best-effort)"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 8
|
||||
if (Phase 8 'Ollama models') {
|
||||
# Expected model set for THIS machine (identity.json prose_model + OLLAMA.md routing):
|
||||
# nomic-embed-text - REQUIRED for GrepAI semantic search (embeddings)
|
||||
# qwen3:8b - prose_model qwen3:14b - heavier prose
|
||||
# codestral:22b - code suggestions qwen3.6:latest - structured/JSON + classify
|
||||
# All five live on D:\OllamaModels (~48 GB) and SURVIVE an OS reset when D: is intact,
|
||||
# so a normal rebuild pulls NOTHING. Only a wiped D: triggers the full re-download.
|
||||
$models = @('nomic-embed-text:latest','qwen3:8b','qwen3:14b','codestral:22b','qwen3.6:latest')
|
||||
if ($SkipModels) { Warn "-SkipModels set, skipping model pulls" }
|
||||
elseif (Have ollama) {
|
||||
if (-not $env:OLLAMA_MODELS) { [Environment]::SetEnvironmentVariable('OLLAMA_MODELS','D:\OllamaModels','User'); $env:OLLAMA_MODELS='D:\OllamaModels' }
|
||||
# GOTCHA (2026-06-06): right after login `ollama list` can return EMPTY even though
|
||||
# D:\OllamaModels is fully populated - the tray app's server needs a few seconds to
|
||||
# hydrate its model-list cache. Do NOT treat an empty list as "models gone" or you
|
||||
# re-download 48 GB for nothing. If manifests are on disk, restart + wait first.
|
||||
$listed = (ollama list 2>$null | Out-String).Trim() -split "`n" | Select-Object -Skip 1
|
||||
if ((Test-Path 'D:\OllamaModels\manifests') -and -not $listed) {
|
||||
Warn "ollama list empty but D:\OllamaModels populated - restarting ollama, waiting for hydration"
|
||||
Get-Process 'ollama','ollama app' -ErrorAction SilentlyContinue | Stop-Process -Force; Start-Sleep 2
|
||||
$oapp = "$env:LOCALAPPDATA\Programs\Ollama\ollama app.exe"
|
||||
if (Test-Path $oapp) { Start-Process $oapp } else { Start-Process ollama -ArgumentList 'serve' -WindowStyle Hidden }
|
||||
Start-Sleep 10
|
||||
}
|
||||
$have = (ollama list 2>$null | Out-String)
|
||||
foreach ($m in $models) {
|
||||
$short = $m -replace ':latest$',''
|
||||
if ($have -match [regex]::Escape($short)) { Ok "$m already present on D:\OllamaModels (no download)" }
|
||||
else { Info "ollama pull $m"; ollama pull $m }
|
||||
}
|
||||
} else { Warn "ollama missing - skip" }
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 9
|
||||
if (Phase 9 'Scheduled tasks') {
|
||||
$tdir = "$script:Bundle\manifests\scheduled-tasks"
|
||||
if ($script:Bundle -and (Test-Path $tdir)) {
|
||||
Get-ChildItem $tdir -Filter *.xml | ForEach-Object {
|
||||
$name = ($_.BaseName -replace '_',' ')
|
||||
try {
|
||||
$xml = Get-Content $_.FullName -Raw
|
||||
Register-ScheduledTask -TaskName $name -Xml $xml -Force -ErrorAction Stop | Out-Null
|
||||
Ok "registered task: $name"
|
||||
} catch { Warn "task '$name' import failed: $($_.Exception.Message) (paths/user may differ - re-create manually)" }
|
||||
}
|
||||
} else { Warn "no exported tasks in bundle - skip (see manifests\scheduled-tasks)" }
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 10
|
||||
if (Phase 10 'Large client data (optional)') {
|
||||
if ($RestoreData -and $script:Bundle -and (Test-Path "$script:Bundle\data")) {
|
||||
Info "restoring large data $script:Bundle\data -> $ClaudeToolsRoot"
|
||||
robocopy "$script:Bundle\data" $ClaudeToolsRoot /E /R:1 /W:1 /NFL /NDL /NP | Out-Null
|
||||
Ok "large data restored"
|
||||
} else { Warn "skipped (pass -RestoreData to restore client data clusters)" }
|
||||
}
|
||||
|
||||
# ============================================================ PHASE 11
|
||||
if (Phase 11 'Verify') {
|
||||
$diag = "$ClaudeToolsRoot\.claude\scripts\onboarding-diagnostic.ps1"
|
||||
if (Test-Path $diag) { Info "running onboarding diagnostic"; & $diag }
|
||||
else { Warn "diagnostic not found - run '/self-check' inside Claude Code to verify wiring" }
|
||||
Write-Host "`n[NEXT] Interactive logins that may need a refresh (tokens expire):" -ForegroundColor Cyan
|
||||
Write-Host " claude (if .credentials.json expired: run 'claude' and /login)"
|
||||
Write-Host " gh auth login op signin gemini (browser) grok login"
|
||||
Write-Host " Verify vault: bash $ClaudeToolsRoot/.claude/scripts/vault.sh list"
|
||||
}
|
||||
|
||||
if ($script:RebootNeeded) {
|
||||
Write-Host "`n[REBOOT] Hostname was changed to '$target' - REBOOT for it to take effect." -ForegroundColor Yellow
|
||||
Write-Host " (scheduled tasks + coord session IDs read the hostname, so reboot before relying on them)"
|
||||
}
|
||||
Write-Host "`n[DONE] windows-bootstrap.ps1 complete." -ForegroundColor Green
|
||||
@@ -14,11 +14,10 @@ Please create a comprehensive git checkpoint with the following steps:
|
||||
- Run `git diff` to see detailed changes in tracked files
|
||||
- Run `git log -5 --oneline` to understand the commit message style of this repository
|
||||
|
||||
3. **Stage everything**:
|
||||
3. **Decide what will be staged** (do NOT stage yet):
|
||||
|
||||
- Add ALL tracked changes (modified and deleted files)
|
||||
- Add ALL untracked files (new files)
|
||||
- Use `git add -A` or `git add .` to stage everything
|
||||
- Identify all tracked changes (modified/deleted) and untracked (new) files via `git status`.
|
||||
- Staging is done **atomically with the commit, under the repo lock, in step 5** — do not run a separate `git add` here. This prevents a concurrent session in a shared worktree (e.g. ClaudeTools) from having its dirty files swept into this checkpoint.
|
||||
|
||||
4. **Draft commit message body via Ollama** (documentation engine):
|
||||
|
||||
@@ -49,7 +48,17 @@ print(res['message']['content'])
|
||||
- **Body**: Ollama draft (Claude reviews); Claude writes directly if Ollama unavailable
|
||||
- **Footer**: `Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`
|
||||
|
||||
5. **Execute the commit**: Create the commit with the properly formatted message following this repository's conventions.
|
||||
5. **Execute the commit (locked)**: Write the final message (summary line + body + footer) to a temp file, then stage + commit **atomically under the repo's commit lock** so concurrent sessions can't interleave or get swept in:
|
||||
|
||||
```bash
|
||||
# MSG = path to the composed commit-message file; LOCK = the shared lock wrapper
|
||||
LOCK="${CLAUDETOOLS_ROOT:-/d/claudetools}/.claude/scripts/sync-lock.sh"
|
||||
bash "$LOCK" run bash -c 'git add -A && git commit -F "$1"' _ "$MSG"
|
||||
```
|
||||
- The lock is scoped to the **current repo** (`git rev-parse --show-toplevel`/.git), so this serializes correctly whether the checkpoint is in ClaudeTools (shares the same lock as `/sync` and `/scc`) or in a project repo (its own lock). The wrapper errors out (exit 2) if you're not in a git repo.
|
||||
- If it **exits 75**, another commit/sync holds the lock — wait briefly and retry, or report "checkpoint deferred".
|
||||
- This is a **local commit only** (no push), matching checkpoint's purpose.
|
||||
- `$CLAUDETOOLS_ROOT` should be set per-machine; the `/d/claudetools` fallback is for this box only — on Mac/Linux it resolves from the env var.
|
||||
|
||||
## Part 2: Verify Git Checkpoint
|
||||
|
||||
|
||||
@@ -1,473 +1,101 @@
|
||||
GuruRMM Feature Request — Comprehensive Analysis & Specification
|
||||
# GuruRMM Feature Request -> RMM Thoughts
|
||||
|
||||
When Howard (or Mike) submits a feature request, conduct full research and produce a detailed specification with implementation recommendations.
|
||||
When Howard (or Mike) submits a GuruRMM feature request, **capture it as a raw entry in
|
||||
the RMM Thoughts backlog** — do NOT jump straight to a full spec or the roadmap. Those
|
||||
are downstream, decision-gated stages.
|
||||
|
||||
Pipeline (see `.claude/memory/feedback_rmm_thoughts_backlog.md`):
|
||||
**THOUGHT (this command, Status: Raw) -> DISCUSS -> SPEC (`/shape-spec` -> `specs/<slug>/`)
|
||||
-> ROADMAP (`docs/FEATURE_ROADMAP.md`) -> BUILD.**
|
||||
|
||||
Backlog doc: `projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Context Loading
|
||||
## Phase 1 — Light triage (Ollama, optional)
|
||||
|
||||
1. **Read identity and machine info:**
|
||||
- `.claude/identity.json` — hostname, user, Ollama endpoint
|
||||
Read `.claude/identity.json` for the user (Howard/Mike) and the Ollama endpoint
|
||||
(`.ollama.endpoint`). Call Ollama `qwen3.6:latest` (strict JSON) for a LIGHT triage —
|
||||
NOT deep research, NOT a spec:
|
||||
|
||||
2. **Read project documentation:**
|
||||
- `projects/msp-tools/guru-rmm/docs/FEATURE_ROADMAP.md` — existing features, structure, priorities
|
||||
- `projects/msp-tools/guru-rmm/docs/UI_GAPS.md` — current UI implementation status
|
||||
- `.claude/CODING_GUIDELINES.md` — code standards, patterns, architecture rules
|
||||
- `projects/msp-tools/guru-rmm/CONTEXT.md` — current project state, tech stack, architecture
|
||||
|
||||
3. **Determine Ollama endpoint:**
|
||||
- `DESKTOP-0O8A1RL`: `http://localhost:11434`
|
||||
- All other machines: `http://100.92.127.64:11434`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Initial Classification (Ollama)
|
||||
|
||||
Call Ollama with model `qwen3.6:latest` (strict JSON) to perform initial classification:
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
You are analyzing a feature request for GuruRMM, a Rust/Axum/TypeScript RMM tool for MSPs.
|
||||
|
||||
Roadmap sections: Core Agent Features, Server/API Features, Dashboard & UI, Platform & Infrastructure, Integrations, Security Features, Future Considerations.
|
||||
|
||||
Feature request: $ARGUMENTS
|
||||
|
||||
Respond with JSON only:
|
||||
{
|
||||
"section": "...",
|
||||
"subsection": "...",
|
||||
"priority": "P1|P2|P3",
|
||||
"brief_summary": "1-2 sentence plain English summary",
|
||||
"similar_features": ["list of similar/related features that might already exist"],
|
||||
"research_needed": ["list of areas requiring investigation before implementation"]
|
||||
}
|
||||
You are triaging a GuruRMM feature request into a backlog. Request: $ARGUMENTS
|
||||
Respond JSON only:
|
||||
{"title": "short kebab-or-title-case name", "summary": "1-2 sentence plain-English summary",
|
||||
"section_guess": "Core Agent | Server/API | Dashboard & UI | Platform | Integrations | Security | Alerting | Other",
|
||||
"priority_guess": "P1|P2|P3"}
|
||||
```
|
||||
|
||||
If Ollama unreachable, perform classification yourself.
|
||||
If Ollama is unreachable, do this triage yourself. Do NOT search the codebase or write a
|
||||
spec at this stage.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Research & Investigation
|
||||
## Phase 2 — Append to RMM Thoughts
|
||||
|
||||
Based on the classification and research_needed list:
|
||||
Append a new entry to the bottom of `projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md`:
|
||||
|
||||
### 3.1 — Codebase Search
|
||||
Search for similar/related implementations:
|
||||
- Use Grep to search for related functionality in `projects/msp-tools/guru-rmm/`
|
||||
- Check `server/src/` for API patterns
|
||||
- Check `agent/src/` for agent-side functionality
|
||||
- Check `dashboard/src/` for UI patterns
|
||||
- Identify existing code that could be extended vs. new code needed
|
||||
|
||||
### 3.2 — External Research (if needed)
|
||||
If the feature involves:
|
||||
- Industry standards (e.g., SNMP, Syslog, API protocols): WebSearch for best practices
|
||||
- Security implications: Research common vulnerabilities and mitigations
|
||||
- Third-party integrations: Check if APIs/SDKs exist
|
||||
- Platform-specific behavior: Research OS-level APIs (Windows/Linux/macOS)
|
||||
|
||||
### 3.3 — Architecture Analysis
|
||||
Consider:
|
||||
- Where does this feature fit in the architecture? (agent, server, dashboard, all three?)
|
||||
- What database schema changes are needed?
|
||||
- What API endpoints are needed?
|
||||
- Are there performance/scalability implications?
|
||||
- Security considerations?
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Consult Coding Guidelines
|
||||
|
||||
Read `.claude/CODING_GUIDELINES.md` and identify relevant patterns:
|
||||
- Error handling requirements
|
||||
- API design patterns
|
||||
- Database conventions
|
||||
- Frontend patterns
|
||||
- Security requirements
|
||||
- Testing requirements
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Specification Generation (Ollama)
|
||||
|
||||
Use Ollama with model `qwen3:14b` (prose) to generate comprehensive specification:
|
||||
|
||||
**Prompt:**
|
||||
```
|
||||
You are writing a detailed implementation specification for a GuruRMM feature.
|
||||
|
||||
FEATURE REQUEST: $ARGUMENTS
|
||||
|
||||
RESEARCH FINDINGS:
|
||||
- Classification: <section/subsection/priority>
|
||||
- Similar existing features: <list>
|
||||
- Codebase search results: <relevant files/patterns found>
|
||||
- External research: <standards, best practices, security considerations>
|
||||
- Architecture fit: <where it belongs in the system>
|
||||
|
||||
CODING GUIDELINES REQUIREMENTS:
|
||||
<relevant excerpts from CODING_GUIDELINES.md>
|
||||
|
||||
Write a comprehensive specification with these sections:
|
||||
|
||||
1. OVERVIEW
|
||||
- What the feature does (2-3 sentences)
|
||||
- User-facing benefit
|
||||
- Primary use cases
|
||||
|
||||
2. SCOPE
|
||||
- What's included in v1
|
||||
- What's explicitly out of scope (for future)
|
||||
- Success criteria
|
||||
|
||||
3. ARCHITECTURE
|
||||
- Components involved (agent/server/dashboard)
|
||||
- Data flow
|
||||
- Database schema changes
|
||||
- API endpoints needed
|
||||
|
||||
4. IMPLEMENTATION DETAILS
|
||||
Agent (if applicable):
|
||||
- Files to modify/create
|
||||
- Rust structs/enums needed
|
||||
- IPC commands (if any)
|
||||
|
||||
Server (if applicable):
|
||||
- API routes
|
||||
- Database migrations
|
||||
- Business logic modules
|
||||
|
||||
Dashboard (if applicable):
|
||||
- New pages/components
|
||||
- State management
|
||||
- API integration
|
||||
|
||||
5. SECURITY CONSIDERATIONS
|
||||
- Authentication/authorization requirements
|
||||
- Input validation
|
||||
- Audit logging
|
||||
- Potential vulnerabilities and mitigations
|
||||
|
||||
6. TESTING STRATEGY
|
||||
- Unit tests needed
|
||||
- Integration tests
|
||||
- Manual test scenarios
|
||||
|
||||
7. ROLLOUT PLAN
|
||||
- Feature flag approach
|
||||
- Backward compatibility
|
||||
- Migration path
|
||||
- Documentation needs
|
||||
|
||||
8. EFFORT ESTIMATE
|
||||
- Small (1-2 days), Medium (3-5 days), Large (1-2 weeks), X-Large (2+ weeks)
|
||||
- Breakdown by component
|
||||
|
||||
Be specific and actionable. Reference actual file paths, struct names, and patterns from the codebase.
|
||||
```
|
||||
|
||||
If Ollama unreachable, write the specification yourself using the research findings.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Roadmap Placement Analysis
|
||||
|
||||
Analyze the FEATURE_ROADMAP.md structure to determine:
|
||||
|
||||
1. **Exact placement:** Which existing subsection does this belong in? Or does it need a new subsection?
|
||||
|
||||
2. **Build sequencing:** Based on the roadmap structure and existing priorities:
|
||||
- What features must be built before this one? (dependencies)
|
||||
- What features does this unblock? (enables)
|
||||
- Which sprint/milestone does this fit into?
|
||||
|
||||
3. **Priority justification:**
|
||||
- P1: Blocks other critical features, security-critical, or MVP requirement
|
||||
- P2: Important for competitive parity, customer requests, or usability
|
||||
- P3: Nice-to-have, future enhancement, or edge case
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Write Specification Document
|
||||
|
||||
Create a new file: `projects/msp-tools/guru-rmm/docs/specs/SPEC-XXX-<feature-name>.md`
|
||||
|
||||
Where XXX is the next available number (check existing specs directory).
|
||||
|
||||
**File format:**
|
||||
```markdown
|
||||
# SPEC-XXX: <Feature Name>
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P1/P2/P3
|
||||
**Requested By:** <Howard|Mike> (<date>)
|
||||
**Estimated Effort:** <Small|Medium|Large|X-Large>
|
||||
## <Title>
|
||||
- Added: <Howard|Mike>, <YYYY-MM-DD> | Status: Raw | section guess: <section> | priority guess: <P?>
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
<2-3 sentence summary>
|
||||
|
||||
**Use Cases:**
|
||||
- <primary use case>
|
||||
- <secondary use case>
|
||||
|
||||
**Success Criteria:**
|
||||
- <measurable criteria>
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
- <feature 1>
|
||||
- <feature 2>
|
||||
|
||||
### Explicitly Out of Scope
|
||||
- <future enhancement>
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
- **Agent:** <what agent does>
|
||||
- **Server:** <what server does>
|
||||
- **Dashboard:** <what dashboard does>
|
||||
|
||||
### Data Flow
|
||||
<step-by-step description or diagram>
|
||||
|
||||
### Database Schema
|
||||
```sql
|
||||
-- New tables or columns
|
||||
<the request, in the submitter's words> <one-line triage summary if it adds clarity>
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
- `POST /api/...` — <description>
|
||||
- `GET /api/...` — <description>
|
||||
Keep it short — it is a RAW thought, not a spec. Do not embellish or design it.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
## Phase 3 — Notify + track
|
||||
|
||||
### Agent (`agent/src/`)
|
||||
**Files to modify:**
|
||||
- `agent/src/xyz.rs` — <what changes>
|
||||
|
||||
**New structs/enums:**
|
||||
```rust
|
||||
// Example code
|
||||
```
|
||||
|
||||
### Server (`server/src/`)
|
||||
**Files to modify:**
|
||||
- `server/src/routes/xyz.rs` — <what changes>
|
||||
|
||||
**Database migrations:**
|
||||
- `migrations/YYYYMMDD_feature_name.sql`
|
||||
|
||||
### Dashboard (`dashboard/src/`)
|
||||
**New components:**
|
||||
- `dashboard/src/components/XyzFeature.tsx` — <description>
|
||||
|
||||
**API integration:**
|
||||
- Use `useQuery` for GET, `useMutation` for POST/PUT
|
||||
- **Coord todo** (so it is visible fleet-wide), via `coord` skill:
|
||||
`todo add "RMM THOUGHT (Raw): <title> — <summary>. See docs/RMM_THOUGHTS.md." --project gururmm --auto --source "feature-request by <who> <date>"`
|
||||
- **If Howard submitted it**, send a coord message so Mike sees it:
|
||||
`msg send ALL "RMM Thought added: <title>" "<who> added a GuruRMM thought (Status: Raw) to docs/RMM_THOUGHTS.md: <summary>. Ready to discuss when you are — not spec'd or roadmapped yet."`
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Authentication:** <requirements>
|
||||
- **Authorization:** <who can access>
|
||||
- **Input Validation:** <validation rules>
|
||||
- **Audit Logging:** <what to log>
|
||||
- **Threat Model:** <potential attacks and mitigations>
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- `agent/tests/xyz_test.rs` — <test scenarios>
|
||||
- `server/tests/api/xyz_test.rs` — <test scenarios>
|
||||
|
||||
### Integration Tests
|
||||
- <end-to-end test scenarios>
|
||||
|
||||
### Manual Testing
|
||||
1. <test step 1>
|
||||
2. <test step 2>
|
||||
|
||||
---
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. **Feature flag:** `feature.xyz.enabled` (default: false)
|
||||
2. **Database migration:** Apply schema changes
|
||||
3. **Agent update:** Deploy agent with feature flag check
|
||||
4. **Dashboard deploy:** UI available when feature enabled
|
||||
5. **Documentation:** Update user guide
|
||||
|
||||
### Backward Compatibility
|
||||
<how older agents/servers handle this>
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Must be completed first:**
|
||||
- <existing feature or infrastructure>
|
||||
|
||||
**Enables future features:**
|
||||
- <what this unblocks>
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
- <question 1>
|
||||
- <question 2>
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
- Related roadmap section: <link>
|
||||
- Similar implementations: <links to code>
|
||||
- External documentation: <links>
|
||||
|
||||
---
|
||||
|
||||
**Next Steps:**
|
||||
1. Review specification with team
|
||||
2. Refine based on feedback
|
||||
3. Move to sprint backlog
|
||||
4. Assign to developer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Update Roadmap
|
||||
|
||||
Add or update the feature in `FEATURE_ROADMAP.md`:
|
||||
|
||||
- If it fits an existing subsection, add it there
|
||||
- If it needs a new subsection, create one
|
||||
- Link to the spec document: `[Feature Name](docs/specs/SPEC-XXX-feature-name.md) - P2`
|
||||
- Add checkboxes for sub-tasks if applicable
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Commit Changes
|
||||
## Phase 4 — Commit (docs-only, gururmm repo)
|
||||
|
||||
```bash
|
||||
cd projects/msp-tools/guru-rmm
|
||||
git add docs/specs/SPEC-XXX-feature-name.md docs/FEATURE_ROADMAP.md
|
||||
git commit -m "spec: add SPEC-XXX <feature name>
|
||||
|
||||
Comprehensive specification for <brief description>.
|
||||
Requested by <Howard|Mike>.
|
||||
|
||||
- Full architecture analysis
|
||||
- Implementation details across agent/server/dashboard
|
||||
- Security considerations
|
||||
- Effort estimate: <Small|Medium|Large|X-Large>
|
||||
- Priority: P1/P2/P3
|
||||
- Added to roadmap under <section>/<subsection>"
|
||||
|
||||
git push origin main
|
||||
git checkout -b docs/rmm-thought-<slug>
|
||||
git add docs/RMM_THOUGHTS.md
|
||||
git commit -m "docs(rmm-thoughts): add thought - <title> (requested by <who>)" # + Co-Authored-By trailer
|
||||
git fetch origin && git rebase origin/main
|
||||
git push origin docs/rmm-thought-<slug>:main
|
||||
git checkout main && git merge --ff-only origin/main && git branch -d docs/rmm-thought-<slug>
|
||||
```
|
||||
|
||||
Then update submodule pointer in parent repo:
|
||||
```bash
|
||||
cd /Users/azcomputerguru/ClaudeTools
|
||||
git add projects/msp-tools/guru-rmm
|
||||
git commit -m "chore: update guru-rmm submodule (SPEC-XXX <feature name>)"
|
||||
git push origin main
|
||||
```
|
||||
Do NOT touch the parent repo submodule pointer.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Send Coord Message (if requested by Howard)
|
||||
## Phase 5 — Respond
|
||||
|
||||
If Howard submitted this (not Mike), send a coord message:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://172.16.3.30:8001/api/coord/messages \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"from_session": "<HOSTNAME>/claude-main",
|
||||
"to_session": "ALL_SESSIONS",
|
||||
"project_key": "gururmm",
|
||||
"subject": "Feature Spec Complete: <feature name>",
|
||||
"body": "Howard submitted a feature request. Full specification created.\n\nSPEC: docs/specs/SPEC-XXX-<feature-name>.md\n\nPriority: <P1/P2/P3>\nEffort: <Small|Medium|Large|X-Large>\nPlacement: <section>/<subsection>\n\nSummary:\n<2-3 sentence summary>\n\nReady for review and sprint planning."
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Response to User
|
||||
|
||||
Provide a comprehensive summary:
|
||||
Tell the user the request was **added to RMM Thoughts at Status: Raw** — summarize it,
|
||||
and say it will be discussed before any spec or roadmap entry. Do NOT claim a spec was
|
||||
created or that it is on the roadmap.
|
||||
|
||||
```
|
||||
[SUCCESS] Feature specification created
|
||||
[OK] Added to RMM Thoughts (Status: Raw)
|
||||
|
||||
SPEC-XXX: <Feature Name>
|
||||
Priority: P1/P2/P3
|
||||
Effort: <Small|Medium|Large|X-Large>
|
||||
Placement: <section>/<subsection>
|
||||
<Title> (section guess: <section> | priority guess: <P?>)
|
||||
<summary>
|
||||
|
||||
OVERVIEW
|
||||
<2-3 sentence summary>
|
||||
|
||||
KEY COMPONENTS
|
||||
- Agent: <brief>
|
||||
- Server: <brief>
|
||||
- Dashboard: <brief>
|
||||
|
||||
SECURITY CONSIDERATIONS
|
||||
- <key security points>
|
||||
|
||||
DEPENDENCIES
|
||||
- Requires: <list>
|
||||
- Enables: <list>
|
||||
|
||||
FILES CREATED
|
||||
- docs/specs/SPEC-XXX-<feature-name>.md (full specification)
|
||||
- Updated FEATURE_ROADMAP.md
|
||||
|
||||
The specification includes:
|
||||
✓ Complete architecture analysis
|
||||
✓ Implementation details for all components
|
||||
✓ Security threat model and mitigations
|
||||
✓ Testing strategy
|
||||
✓ Rollout plan with feature flags
|
||||
✓ Effort breakdown
|
||||
|
||||
<If Howard submitted:>
|
||||
Coord message sent to Mike for review and sprint planning.
|
||||
|
||||
<Next steps based on priority:>
|
||||
P1: Schedule for immediate sprint
|
||||
P2: Add to near-term backlog
|
||||
P3: Track for future consideration
|
||||
Next: we discuss it -> /shape-spec if approved -> roadmap -> build.
|
||||
Tracked: coord todo <id>.<if Howard: coord message sent to Mike.>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If Ollama unreachable: Perform all analysis yourself (no degradation)
|
||||
- If coord API fails: Warn user but continue (they can manually notify Mike)
|
||||
- If spec number conflicts: Check existing specs and use next available
|
||||
- If roadmap section unclear: Create new subsection rather than force-fit
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This command can take 2-5 minutes due to research and specification generation
|
||||
- The specification is a living document — can be refined during sprint planning
|
||||
- Feature flags ensure safe rollout even for partially complete features
|
||||
- Effort estimates are initial and may be revised during implementation
|
||||
- This command does NOT auto-create a SPEC-XXX doc or a roadmap entry anymore. The old
|
||||
behaviour (full Ollama spec generation + roadmap edit on every request) jumped past the
|
||||
discuss stage; spec work now happens via `/shape-spec` once a thought is approved.
|
||||
- To advance a thought later: discuss it (-> Status: Discussed), `/shape-spec` it
|
||||
(-> Spec'd, `specs/<slug>/`), then add it to `FEATURE_ROADMAP.md` (-> Roadmapped).
|
||||
- Ollama unreachable: do the triage yourself, no degradation. Coord API down: warn and
|
||||
continue (the doc commit is the durable record).
|
||||
|
||||
@@ -162,11 +162,13 @@ Allowed actions and which tier handles them:
|
||||
|---|---|---|
|
||||
| `revoke-sessions` | `user-manager` | Graph `POST /users/{upn}/revokeSignInSessions` |
|
||||
| `disable-account` | `user-manager` | Graph `PATCH /users/{upn}` with `accountEnabled: false` |
|
||||
| `password-reset` | `user-manager` | Graph `PATCH /users/{upn}` with new `passwordProfile` |
|
||||
| `password-reset` | `tenant-admin` | `scripts/reset-password.sh <tenant> <upn> <new-pw> [--force-change]` (Graph `PATCH /users/{upn}` passwordProfile, with JIT admin elevation — see note) |
|
||||
| `disable-forwarding` | `exchange-op` | Exchange REST `Set-Mailbox -ForwardingAddress $null -ForwardingSmtpAddress $null -DeliverToMailboxAndForward $false` |
|
||||
| `remove-inbox-rules` | `exchange-op` | Exchange REST `Remove-InboxRule` per non-default rule (ask which to keep first) |
|
||||
| `disable-smtp-auth` | `exchange-op` | Exchange REST `Set-CASMailbox -SmtpClientAuthenticationDisabled $true` |
|
||||
|
||||
**Password reset of admin-role accounts (JIT elevation):** A plain `passwordProfile` PATCH works for ordinary members but returns `403 Authorization_RequestDenied` when the target holds a directory role (SharePoint/Teams/User Admin, etc.) — Microsoft requires the caller to be Global Administrator or **Privileged Authentication Administrator** to reset an admin's password. `scripts/reset-password.sh` handles this: it tries the direct reset, and on 403 it assigns the Tenant Admin service principal the Privileged Authentication Administrator role (the app holds `RoleManagement.ReadWrite.Directory`), retries, then **removes the role assignment it created** (de-elevates). If the SP already held the role, it is left untouched. Default `forceChangePasswordNextSignIn=false` (permanent — right for shared/service accounts); pass `--force-change` for a user who must change at next sign-in. Requires the tenant to have consented the Tenant Admin app. (Pattern added 2026-06-08 — birthbiologic.com operations@ was a SharePoint+Teams Admin, blocking the plain reset.)
|
||||
|
||||
---
|
||||
|
||||
## Arguments
|
||||
@@ -184,6 +186,44 @@ If the user's phrasing is loose ("check john's box at cascades", "who's being at
|
||||
|
||||
---
|
||||
|
||||
## Syncro Ticket Creation (after remediation or check)
|
||||
|
||||
When creating a Syncro ticket to log remediation or breach-check work — whether via `/syncro` at the end of the session or inline during the workflow — the following fields are **REQUIRED** and must always be present in the POST payload. Omitting any of them leaves the ticket unusable in the queue.
|
||||
|
||||
**Required fields — no exceptions:**
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `priority` | Always `"2 Normal"` unless the incident is active/emergency, in which case `"4 Urgent"` |
|
||||
| `user_id` | Always the API key owner's user ID: `mike` → `1735`, `howard` → `1750`, `winter` → `1737`. Never omit — never null |
|
||||
| `problem_type` | Use `"Security"` for breach checks, tenant sweeps, MFA enforcement, account compromise. Use `"Remote"` for general M365 remote support. Never use `"Remote Support"` — it is not a valid Syncro dropdown value and will appear blank in the GUI |
|
||||
|
||||
**Payload template for POST /tickets:**
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- <<JSON
|
||||
{
|
||||
"customer_id": ${CUST_ID},
|
||||
"subject": "<subject>",
|
||||
"problem_type": "Security",
|
||||
"status": "New",
|
||||
"priority": "2 Normal",
|
||||
"user_id": ${TECH_USER_ID}
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
**Enforcement checklist — verify before POSTing:**
|
||||
1. `priority` is set (not null, not omitted)
|
||||
2. `user_id` is set to the correct tech ID (not null, not omitted)
|
||||
3. `problem_type` is one of the valid Syncro dropdown values listed above
|
||||
|
||||
If any check fails, fix the payload before sending. Do not POST a ticket with missing required fields.
|
||||
|
||||
---
|
||||
|
||||
## Scope and references
|
||||
|
||||
- Detailed check rubric: `.claude/skills/remediation-tool/references/checklist.md`
|
||||
|
||||
@@ -67,28 +67,31 @@ Interact with the GuruRMM agent fleet: list agents, run remote commands (PowerSh
|
||||
|
||||
## Phase 0 — Bootstrap (run once per session)
|
||||
|
||||
**Use the helper script** (cross-platform, handles Mac jq/JSON issues):
|
||||
|
||||
```bash
|
||||
IDENTITY_PATH="${HOME}/.claude/identity.json"
|
||||
if [ ! -f "$IDENTITY_PATH" ]; then
|
||||
IDENTITY_PATH=$(git rev-parse --show-toplevel 2>/dev/null)/.claude/identity.json
|
||||
fi
|
||||
REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH" 2>/dev/null)
|
||||
if [ -z "$REPO_ROOT" ]; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
fi
|
||||
VAULT="$REPO_ROOT/.claude/scripts/vault.sh"
|
||||
# Authenticate and set environment variables
|
||||
eval "$(bash .claude/scripts/rmm-auth.sh)"
|
||||
# This sets: $TOKEN, $RMM, $REPO_ROOT
|
||||
```
|
||||
|
||||
**Alternative (manual, for reference only — use helper script above):**
|
||||
|
||||
```bash
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
|
||||
IDENTITY_FILE="$REPO_ROOT/.claude/identity.json"
|
||||
VAULT_PATH=$(jq -r '.vault_path' "$IDENTITY_FILE")
|
||||
VAULT_SH="$VAULT_PATH/scripts/vault.sh"
|
||||
RMM="http://172.16.3.30:3001"
|
||||
|
||||
RMM_EMAIL=$(bash "$VAULT" get-field infrastructure/gururmm-server.sops.yaml credentials.gururmm-api.admin-email)
|
||||
RMM_PASS=$(bash "$VAULT" get-field infrastructure/gururmm-server.sops.yaml credentials.gururmm-api.admin-password)
|
||||
RMM_EMAIL=$(bash "$VAULT_SH" get-field infrastructure/gururmm-server.sops.yaml credentials.gururmm-api.admin-email)
|
||||
RMM_PASS=$(bash "$VAULT_SH" get-field infrastructure/gururmm-server.sops.yaml credentials.gururmm-api.admin-password)
|
||||
|
||||
JWT=$(curl -s -X POST "$RMM/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- <<JSON
|
||||
{"email": "$RMM_EMAIL", "password": "$RMM_PASS"}
|
||||
JSON
|
||||
)
|
||||
# Use jq to build JSON safely (avoids heredoc issues on Mac)
|
||||
PAYLOAD=$(jq -n --arg email "$RMM_EMAIL" --arg password "$RMM_PASS" '{email: $email, password: $password}')
|
||||
JWT=$(curl -s -X POST "$RMM/api/auth/login" -H "Content-Type: application/json" -d "$PAYLOAD")
|
||||
TOKEN=$(echo "$JWT" | jq -r '.token // empty')
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "[ERROR] RMM login failed: $JWT"
|
||||
exit 1
|
||||
|
||||
@@ -26,17 +26,35 @@ Claude writes all sections directly. Be concise, factual, technical. No filler p
|
||||
|
||||
### Location
|
||||
|
||||
New logs go in a **`YYYY-MM/` month folder** under the relevant `session-logs/` dir (keeps the
|
||||
flat dir from growing unbounded; recall is scoped grep over the month folders — no monolithic
|
||||
index). `mkdir -p` the month folder before writing.
|
||||
|
||||
| Work scope | Path |
|
||||
|---|---|
|
||||
| Single project | `projects/<project>/session-logs/YYYY-MM-DD-session.md` |
|
||||
| Client | `clients/<slug>/session-logs/YYYY-MM-DD-session.md` |
|
||||
| Multi-project / general | `session-logs/YYYY-MM-DD-session.md` |
|
||||
| Single project | `projects/<project>/session-logs/YYYY-MM/YYYY-MM-DD-<user>-<topic>.md` |
|
||||
| Client | `clients/<slug>/session-logs/YYYY-MM/YYYY-MM-DD-<user>-<topic>.md` |
|
||||
| Multi-project / general | `session-logs/YYYY-MM/YYYY-MM-DD-<user>-<topic>.md` |
|
||||
|
||||
> Existing flat logs (`session-logs/*.md`) stay where they are — recall grep covers both `*/*.md`
|
||||
> (month folders) and `*.md` (legacy flat), so no mass migration. The month folder is added
|
||||
> *after* `session-logs/`, so wiki slug derivation (`<project>`/`<slug>` captured before
|
||||
> `session-logs/`) is unaffected. Use `bash .claude/scripts/now-phoenix.sh --date` for the date.
|
||||
|
||||
### Filename + append behavior
|
||||
|
||||
- Filename: `YYYY-MM-DD-session.md` (today's local date)
|
||||
- If file exists, **append** a `## Update: HH:MM PT — <topic>` section. Do not overwrite.
|
||||
- If two users worked on the same date, namespace: `YYYY-MM-DD-<user>-<topic>.md` (e.g. `2026-05-01-howard-syncro-billing-batch.md`)
|
||||
**Per-session-unique filenames are mandatory** — 3–4 Claude sessions can run against this one
|
||||
working tree at once, and a shared `YYYY-MM-DD-session.md` lets them overwrite each other's logs.
|
||||
Never use the bare `YYYY-MM-DD-session.md`.
|
||||
|
||||
- Default: `YYYY-MM-DD-<user>-<topic>.md` — `<user>` from the User block (identity.json),
|
||||
`<topic>` a short kebab slug of this session's main work (e.g. `2026-06-05-mike-gururmm-platform-day.md`).
|
||||
The topic naturally separates concurrent sessions.
|
||||
- Collision guard: if that exact filename already exists and belongs to a **different** session
|
||||
(different work), append a discriminator — `YYYY-MM-DD-<user>-<topic>-2.md` (increment until free).
|
||||
Never overwrite another session's file.
|
||||
- Same-session continuation (re-saving your own ongoing work): **append** a
|
||||
`## Update: HH:MM PT — <topic>` section to this session's own file. Do not overwrite.
|
||||
|
||||
### Required sections (in order)
|
||||
|
||||
@@ -59,25 +77,26 @@ When in doubt, include MORE detail — future sessions search these logs to reco
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Wiki Compile (before sync)
|
||||
## Phase 3 — Wiki: DECOUPLED (do NOT recompile inline)
|
||||
|
||||
Fold what you just worked on into the wiki article so it ships in the **same commit** as the session log. This runs before sync and **re-synthesizes** the article (via a **Sonnet subagent** — `model: "sonnet"`, not Ollama), so new findings/patterns actually land — not just dynamic fields.
|
||||
Wiki synthesis is **decoupled from `/save`** (harness v1.2.0+, Task 2). Running a full
|
||||
Sonnet recompile inline on every save, on every machine, caused concurrent-recompile
|
||||
rebase conflicts — and once committed unresolved conflict markers into a wiki article.
|
||||
So **`/save` no longer touches the wiki**: it writes the session log and syncs, nothing
|
||||
more. Do NOT recompile the wiki here, and never block/delay the sync on wiki work.
|
||||
|
||||
1. Derive the slug from the session-log path written in Phase 2:
|
||||
- `clients/<slug>/session-logs/...` → client `<slug>`
|
||||
- `projects/<project>/session-logs/...` → project article slug (e.g. `guru-rmm`, `guru-connect`)
|
||||
- Root `session-logs/...` → **skip this phase entirely** (no single article is implied)
|
||||
To refresh the wiki for this session's work, run `/wiki-compile` **separately** — it is
|
||||
now **serialized** (per-article coord lock) and **staged** (writes a proposed update to
|
||||
`.claude/wiki_staging/` for review before it touches the live article).
|
||||
|
||||
2. Run the `/wiki-compile` generation for that target, writing the article + updating `wiki/index.md`, but **stop before its commit/push step** — `sync.sh` (Phase 4) commits everything together in one commit:
|
||||
- **Article exists** → **full recompile** (`/wiki-compile <type>:<slug> --full`): the Sonnet subagent re-synthesizes, **preserving Patterns and History verbatim** (unless the new session log shows an item resolved) and refreshing everything else, absorbing this session's work. Clients also refresh live Syncro fields (hours, tickets).
|
||||
- **No article yet** → **seed** (full synthesis) to create it.
|
||||
- The main agent reviews the subagent's draft before writing — verify IPs/paths; never invent vault paths (use `(verify)`); keep billing fields Syncro-authoritative.
|
||||
After the sync completes, derive the slug from the session-log path (Phase 2) and emit
|
||||
the exact command for the operator to run when ready:
|
||||
- `clients/<slug>/session-logs/...` → `[INFO] Wiki decoupled — run: /wiki-compile client:<slug> --full (serialized + staged)`
|
||||
- `projects/<project>/session-logs/...` → `[INFO] Wiki decoupled — run: /wiki-compile project:<slug> --full (serialized + staged)`
|
||||
- Root `session-logs/...` → no single article implied; emit nothing.
|
||||
|
||||
3. **Softfail (critical) — a wiki failure must NEVER block the save:**
|
||||
- If the synthesis subagent fails or is unavailable, fall back to a surgical **refresh** (bump `last_compiled` + `sources`; refresh client Syncro fields) so the article still records the session, and emit `[WARN] wiki refreshed, not recompiled; run /wiki-compile --full later`.
|
||||
- Any other failure: log it and continue to sync.
|
||||
|
||||
The article + `wiki/index.md` are picked up by `sync.sh`'s `git add -A` and committed alongside the session log.
|
||||
The session log + `sync.sh` are the durable record; the wiki is refreshed deliberately,
|
||||
not on every save.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +106,11 @@ The article + `wiki/index.md` are picked up by `sync.sh`'s `git add -A` and comm
|
||||
bash .claude/scripts/sync.sh
|
||||
```
|
||||
|
||||
`sync.sh` handles: reconcile this machine's `git config user.name/email` to `.claude/identity.json` (so commit authorship can't drift), stage all changes with `git add -A` (after purging garbled Windows path-as-filename cruft), auto-commit, fetch + rebase, push, then the same flow for the vault repo, then surface cross-user `## Note for <user>` blocks.
|
||||
Same driver as `/sync` — see that command for the full semantics. The two load-bearing
|
||||
points for reporting: **exit 75 = deferred** (another sync is running; report "sync deferred
|
||||
— your session log is written locally and will sync on the next run", NOT a success summary);
|
||||
and `git add -A` is a catch-all sweep, so avoid running `/save` from two sessions at the exact
|
||||
same moment (per-session-unique log filenames prevent log overwrites, the lock prevents racing).
|
||||
|
||||
After sync, emit a **Post-commit Summary**:
|
||||
|
||||
|
||||
@@ -6,24 +6,17 @@ Quick command to save session log, stage everything, and push to Gitea in one sh
|
||||
|
||||
1. **Save session log** - Create/update session log for today using the /save skill logic:
|
||||
- Determine correct location based on work context (project-specific or general `session-logs/`)
|
||||
- Use format `YYYY-MM-DD-session.md`
|
||||
- If file exists, append with `## Update: HH:MM` header
|
||||
- **Per-session-unique filename (mandatory)** — concurrent sessions share this worktree, so never use the bare `YYYY-MM-DD-session.md`. Use `YYYY-MM-DD-<user>-<topic>.md`; collision-guard + same-session-append rules are in `/save` (`save.md`).
|
||||
- Include: summary, credentials (unredacted), infrastructure, commands, files changed, pending tasks
|
||||
|
||||
2. **Stage all changes** - Run `git add -A` to stage everything including the new session log
|
||||
2. **Commit + push (locked, rebase-safe)** - Run `bash .claude/scripts/sync.sh`. This is the single serialized git path: it takes the per-machine sync lock (so it can't interleave with another session's sync/commit), reconciles git identity to `identity.json`, stages changes, commits, fetch + rebase, pushes — ClaudeTools then vault.
|
||||
- **Do NOT** run raw `git add -A` / `git commit` / `git push origin main` here — that bypasses the lock AND the fetch+rebase (the old flow raced and would reject on a stale push).
|
||||
- If `sync.sh` **exits 75**, another sync is in progress: report "sync deferred — your log is saved locally and will sync on the next run"; do not claim pushed.
|
||||
- Note: the discrete `scc:`-prefixed message is dropped in favour of one locked git path (commit lands under `sync.sh`'s auto message). If a custom message matters, revisit later (e.g. a `-m` arg on `sync.sh`).
|
||||
|
||||
3. **Commit** - Auto-commit with message:
|
||||
```
|
||||
scc: Session save and push from [hostname] at [timestamp]
|
||||
3. **Report** - Confirm what was saved, committed, and pushed (or deferred)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
4. **Push to Gitea** - Run `git push origin main`
|
||||
|
||||
5. **Report** - Confirm what was saved, committed, and pushed
|
||||
|
||||
6. **Reaffirm roles** - After push, briefly restate:
|
||||
4. **Reaffirm roles** - After push, briefly restate:
|
||||
- You are a COORDINATOR, not an executor
|
||||
- Delegate: DB -> Database Agent, code -> Coding Agent, git -> Gitea Agent, tests -> Testing Agent
|
||||
- Do yourself: simple responses, reading 1-2 files, planning, decisions
|
||||
|
||||
@@ -39,16 +39,15 @@ The intent: a `/sync` that finds unsaved work should default toward `/save`. Aut
|
||||
|
||||
## What this does
|
||||
|
||||
Invokes `bash .claude/scripts/sync.sh`, which:
|
||||
Run it — the script is the single source of truth for all git ops (both `/sync` and `/save` invoke it):
|
||||
|
||||
1. Detects local changes (including untracked-only files) via `git status --porcelain`; stages with `git add -A` and auto-commits with `sync: auto-sync from <hostname> at <timestamp>`
|
||||
2. Fetches from origin, rebases local commits onto remote
|
||||
3. Pushes to origin
|
||||
4. Copies `.claude/commands/*.md` → `~/.claude/commands/` so the global Claude CLI commands stay current without a manual copy
|
||||
5. Repeats steps 1-3 for the **vault** repo (path read from `.claude/identity.json` `vault_path` field)
|
||||
6. Surfaces any `## Note for <user>` / `## Message for <user>` blocks from incoming session logs
|
||||
```bash
|
||||
bash .claude/scripts/sync.sh
|
||||
```
|
||||
|
||||
The script is the single source of truth for git operations. Both `/sync` and `/save` invoke it.
|
||||
It stages (`git add -A`, submodule gitlinks unstaged unless `--with-submodules`), auto-commits, fetch+rebase+push for this repo then the vault repo, deploys `.claude/commands/*.md` + skills to `~/.claude/`, and surfaces incoming `## Note for <user>` blocks. Full internals: `.claude/CLAUDE_EXTENDED.md` / the script header.
|
||||
|
||||
**Exit 75 = deferred, not a failure.** The run is serialized by a per-machine lock (`.git/claudetools-sync.lock`); if another sync is mid-flight it waits ~120s then exits 75. On a 75, report "sync deferred — another sync is running; it will catch up next run", NOT a success summary. Stale locks (dead owner, or >10 min) auto-reclaim.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Create, update, close, comment on, and bill tickets in Syncro PSA.
|
||||
|
||||
## Hard Rules (violations have occurred — no exceptions)
|
||||
|
||||
**Billing uses `add_line_item` directly — do NOT use `timer_entry → charge_timer_entry`.** The timer workflow is not used. For all billable work (labor, warranty, internal), POST directly to `/tickets/<id>/add_line_item` with the correct `product_id`, `name`, `quantity` (decimal hours), `price_retail`, `description`, and `taxable: false`. The `name` field is required — Syncro returns `{"errors":"Name can't be blank"}` if omitted (verified 2026-05-21 on Cascades #32313).
|
||||
**Normal billing uses `add_line_item` directly — do NOT use `timer_entry → charge_timer_entry` for routine billing.** Timers are an OUTLIER: use one ONLY if Mike explicitly requests a timer for a specific job, never for the normal billing loop. For all billable work (labor, warranty, internal), POST directly to `/tickets/<id>/add_line_item` with the correct `product_id`, `name`, `quantity` (decimal hours), `price_retail`, `description`, and `taxable: false`. The `name` field is required — Syncro returns `{"errors":"Name can't be blank"}` if omitted (verified 2026-05-21 on Cascades #32313).
|
||||
|
||||
**JSON payloads to curl: use heredoc with `--data-binary @-`, not `/tmp/*.json` files.** On Windows the Write tool resolves `/tmp/foo.json` to `C:\tmp\foo.json` while Git Bash resolves it to `%LOCALAPPDATA%\Temp\foo.json` — different real directories, so a payload written by Write may not be the file curl reads. Heredoc with `<<'JSON'` (single-quoted to suppress bash variable expansion inside the payload) avoids the file handoff entirely. See `.claude/memory/feedback_tmp_path_windows.md` — caused a wrong-comment incident on ticket #32225 on 2026-05-01 (rogue payload from a prior session).
|
||||
|
||||
@@ -618,7 +618,7 @@ curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{id: .customer.i
|
||||
|
||||
#### Line Items
|
||||
|
||||
All billing uses `add_line_item` directly. Do not use `timer_entry → charge_timer_entry`. Do not use timers.
|
||||
Normal billing uses `add_line_item` directly. Do not use `timer_entry → charge_timer_entry` for routine billing. Timers are an outlier — use one only when Mike explicitly requests a timer for a specific job (see `.claude/standards/syncro/time-entry-protocol.md`).
|
||||
|
||||
**Dead-end paths (all return 404 — do not probe):**
|
||||
- `POST /ticket_line_items` — does not exist
|
||||
|
||||
@@ -342,12 +342,33 @@ If the subagent is unavailable, the main agent writes the article directly using
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Write Article + Update Index
|
||||
## Phase 5 — Serialize, Stage, Review, Apply (Task 2)
|
||||
|
||||
**Write the article:**
|
||||
- Seed: write `wiki/clients/<slug>.md` from generated content
|
||||
- Full: overwrite `wiki/clients/<slug>.md`
|
||||
- Refresh: edits already applied in Phase 4
|
||||
Wiki writes are SERIALIZED + STAGED so two machines never recompile the same article
|
||||
into a conflict, and no synthesis lands in the live article without a review.
|
||||
|
||||
**5.0 Claim a per-article coord lock** (via the `coord` skill):
|
||||
`lock claim claudetools wiki/<type>/<slug> "wiki-compile <slug>" --ttl 1`.
|
||||
- The TTL auto-evicts a dead session's lock (no permanent stranding).
|
||||
- If the lock is **already held** → emit `[SKIP] wiki/<type>/<slug> is being compiled on
|
||||
another machine; try again shortly` and exit cleanly.
|
||||
- If **coord is unreachable** → emit `[WARN] coord down — proceeding without lock` and continue.
|
||||
- RELEASE the lock in 5.3 — and on ANY error/abort before then.
|
||||
|
||||
**5.1 Write the synthesized article to STAGING, not the live tree:**
|
||||
- Staging path: `.claude/wiki_staging/<type>-<slug>.md` (`mkdir -p .claude/wiki_staging`).
|
||||
Write the generated/recompiled article THERE. Do NOT touch `wiki/...` yet.
|
||||
|
||||
**5.2 Review the staged diff (NO blind merge):**
|
||||
- `diff -u "<live wiki path>" ".claude/wiki_staging/<type>-<slug>.md" | head -120` (or
|
||||
`(new article)` if none). The main agent reviews: Patterns/History preserved on full
|
||||
recompile, IPs/paths/vault-paths accurate, billing Syncro-authoritative, NO structural
|
||||
corruption or duplicated headers. If the diff looks wrong → STOP, fix the staged file or
|
||||
abort (release the lock); do not apply.
|
||||
|
||||
**5.3 Apply the staged article to the live tree** (then index + commit in Phase 6):
|
||||
- `cp .claude/wiki_staging/<type>-<slug>.md <live wiki path>` (seed/full); refresh edits
|
||||
already applied in Phase 4 still go via this staging review.
|
||||
|
||||
**Update `wiki/index.md`:**
|
||||
- Check if `wiki/clients/<slug>.md` is listed in the Clients table
|
||||
@@ -366,7 +387,11 @@ If the subagent is unavailable, the main agent writes the article directly using
|
||||
cd "$CLAUDETOOLS_ROOT"
|
||||
git add "wiki/clients/${SLUG}.md" wiki/index.md
|
||||
git commit -m "wiki: compile ${SLUG} (${MODE})"
|
||||
git fetch origin && git rebase origin/main # serialized, but rebase defensively
|
||||
git push origin main
|
||||
# Release the per-article lock and clear staging (ALWAYS — even on an earlier abort):
|
||||
$PY "$CLAUDETOOLS_ROOT/.claude/skills/coord/scripts/coord.py" lock release claudetools "wiki/${TYPE}/${SLUG}" 2>/dev/null || true
|
||||
rm -f "$CLAUDETOOLS_ROOT/.claude/wiki_staging/${TYPE}-${SLUG}.md"
|
||||
```
|
||||
|
||||
Emit:
|
||||
|
||||
81
.claude/harness/CHANGELOG.md
Normal file
81
.claude/harness/CHANGELOG.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Harness CHANGELOG
|
||||
|
||||
The ClaudeTools harness version marker (`.claude/harness/VERSION`). Bump on every
|
||||
fleet-visible behavioral change so a session can detect whether it is running the new
|
||||
or old harness during a heterogeneous rollout. See
|
||||
`specs/claudetools-harness-optimization/`.
|
||||
|
||||
## 1.0.0 — 2026-06-08
|
||||
- Task 0.5: VERSION marker established (this file).
|
||||
- Task 0.6: out-of-band recovery script `.claude/scripts/force-pull-raw.sh` added.
|
||||
- (Earlier) Syncro billing SSOT resolved: `add_line_item` is normal billing; timers are
|
||||
outlier-only (explicit request).
|
||||
|
||||
## 1.1.0 — 2026-06-08
|
||||
- Task 1: submodule-safe sync — `sync.sh` now unstages submodule gitlinks (unless
|
||||
`--with-submodules`), eliminating the manual detach-to-pin dance before /save.
|
||||
- Task 4: `harness-guard.sh` wired into `sync.sh` pre-commit, WARN-ONLY (logs conflict
|
||||
markers / unencrypted sops / private keys to .claude/harness/guard.log; does not block
|
||||
unless HARNESS_GUARD_FATAL=1; SKIP_HARNESS_GUARD=1 bypasses).
|
||||
|
||||
## 1.2.0 — 2026-06-08
|
||||
- Task 2: wiki synthesis DECOUPLED from /save (the concurrent-recompile conflict source).
|
||||
/save now only writes the log + syncs and emits the exact /wiki-compile command to run.
|
||||
/wiki-compile is now SERIALIZED (per-article coord lock, TTL orphan-evict, coord-down =
|
||||
warn+proceed) and STAGED (writes .claude/wiki_staging/<type>-<slug>.md -> review diff ->
|
||||
apply to live -> commit -> release lock). No blind background auto-merge.
|
||||
|
||||
## 1.3.0 — 2026-06-08
|
||||
- Task 6: CLAUDE.md split into lean CORE (1.2k tokens, always loaded) + CLAUDE_EXTENDED.md
|
||||
(full manual, on-demand). Saves ~3.7k tokens per CLAUDE.md injection; nothing lost.
|
||||
- Task 9 (P2): delegation re-tuned in CORE — act directly by default; delegate only for
|
||||
high-volume output, blast radius >3 files/layers, domain shift, or parallel work.
|
||||
|
||||
## 1.4.0 — 2026-06-08 (P1+P2+P3 complete)
|
||||
- Task 5: one-line registry descriptions on the 8 biggest skills (remediation-tool, gc-audit,
|
||||
packetdial, memory-dream, human-flow, self-check, impeccable, mailprotector). Skill-description
|
||||
injection ~3320 -> ~2123 tokens (~36% cut); keyword triggers preserved; frontmatter valid.
|
||||
- Task 7: thinned `/save` + `/sync` bodies — they point to `sync.sh` as the single source instead
|
||||
of re-documenting its internals; load-bearing LLM-judgment parts (Phase 0 save-vs-sync, cross-user
|
||||
note display, exit-75 reporting) kept verbatim. The mechanical sync never depends on an LLM step.
|
||||
- Task 10 (P3): `session-logs/YYYY-MM/` adopted as a FORWARD convention for new logs (recall = scoped
|
||||
grep over month folders, no monolithic index); existing flat logs untouched (grep covers both).
|
||||
Recall order (wiki -> CONTEXT/log -> coord) already lives in CORE.
|
||||
- Deterministic Bash fix: `now-phoenix.sh` helper added — fixed UTC-7 epoch math, replaces the
|
||||
unreliable `TZ=America/Phoenix date` (silently returns UTC on Git-Bash). `--iso/--date/--datetime/
|
||||
--fmt` formats. `post-bot-alert.sh` already uses `jq -nc --arg` (verified, no change needed).
|
||||
- Deferred (unchanged): full Python port = separate spec; Task 8 shard command bodies; promote
|
||||
guard to FATAL after a clean warn window; schedule memory-dream --apply-safe per-machine.
|
||||
|
||||
## 1.4.1 — 2026-06-08 (Task 12: self-check smoke tests)
|
||||
- /self-check gained a `harness` category that locks in the 1.4.0 invariants (all read-only):
|
||||
VERSION present + not older than manifest min_version; **skill-registry description budget**
|
||||
(sum of all SKILL.md description: fields under manifest.harness.registry_desc_budget_chars —
|
||||
WARN on regrowth, the metric that would catch Task 5 bloating back); global deploy targets
|
||||
~/.claude/skills + ~/.claude/commands populated (the Mac-wipe failure); harness-guard.sh wired
|
||||
into sync.sh; core scripts parse (bash -n on sync/guard/now-phoenix); now-phoenix.sh emits a
|
||||
valid date. Tunables live in baseline/manifest.json `harness` block. Verified: 9/9 PASS on this
|
||||
machine; budget WARN trips correctly on a synthetic over-budget value.
|
||||
- Also reconciled the remaining "GrepAI first" docs (standard + CODING_GUIDELINES) with the
|
||||
wiki-first recall hierarchy (started in CLAUDE_EXTENDED).
|
||||
|
||||
## 1.4.2 — 2026-06-08 (Task 3 leftover: command-restates-standard lint)
|
||||
- /self-check gained a `consistency` category — the command-restates-standard lint. Deterministic
|
||||
half: for each manifest.command_standard_links pair, the standard must still carry its
|
||||
defer-to-SSOT pointer to the owning command; a lost pointer WARNs (the standard likely drifted
|
||||
back into restating the command — the Syncro-timers failure mode). Seeded with the syncro-billing
|
||||
link (time-entry-protocol.md -> /syncro). Semantic contradiction pass (read both, judge actual
|
||||
conflict) delegated to the model in SKILL.md, mirroring the memory pass. Verified PASS; negative-
|
||||
tested (WARN fires when the pointer is removed). New pairs: add to manifest.command_standard_links.
|
||||
|
||||
## 1.4.3 — 2026-06-08 (guard FATAL-promotion prerequisite: test matrix + refinement)
|
||||
- Built `.claude/scripts/test-harness-guard.sh` — a 12-case false-positive/true-positive matrix
|
||||
for harness-guard.sh (spins a throwaway repo, stages synthetic content, runs the REAL guard,
|
||||
asserts WARN/clean). Required by the plan before promoting the guard to FATAL.
|
||||
- The matrix surfaced a false-positive vector: the conflict rule's lone `=======$` alternative
|
||||
fired on a markdown setext underline / divider of exactly seven `=`. REFINED harness-guard.sh to
|
||||
require a real hunk — BOTH `^<<<<<<< ` AND `^>>>>>>> ` present — which has identical true-positive
|
||||
power (git always writes all three markers) and eliminates the false positive. Verified 12/12 pass;
|
||||
real-tree false-positive surface = 0.
|
||||
- Wired the matrix into /self-check as `harness.guard_selftest` (runs in an isolated temp repo, so
|
||||
the read-only-vs-real-tree contract holds). The eventual FATAL flip is now evidence-backed.
|
||||
1
.claude/harness/VERSION
Normal file
1
.claude/harness/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
1.4.3
|
||||
85
.claude/machines/guru-5070.md
Normal file
85
.claude/machines/guru-5070.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Machine: GURU-5070 (Windows)
|
||||
|
||||
**Hostname:** GURU-5070
|
||||
**User:** Mike Swanson (mike) — admin
|
||||
**Platform:** Windows 11 Pro 10.0.26200
|
||||
**Last Updated:** 2026-06-06
|
||||
|
||||
> Same physical hardware as `acg-guru-5070.md` (Lenovo Legion Pro 7 16IAX10H) —
|
||||
> that profile documents the prior CachyOS Linux install. This box now runs Windows.
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| Model | Lenovo Legion Pro 7 16IAX10H (DMI 83F5) |
|
||||
| CPU | Intel Core Ultra 9 275HX (24 cores) |
|
||||
| Memory | 32 GB DDR5 |
|
||||
| GPU | NVIDIA GeForce RTX 5070 Ti Laptop (12 GB) |
|
||||
| Disks | C: 952 GB NVMe (OS), D: 953 GB NVMe (dev — `D:\claudetools`, `D:\vault`, `D:\work`) |
|
||||
|
||||
## Paths
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| ClaudeTools | `D:\claudetools` |
|
||||
| Vault | `D:\vault` |
|
||||
| Other repos | `D:\work\gururmm` |
|
||||
| SOPS age key | `%APPDATA%\sops\age\keys.txt` and `~\.config\sops\age\keys.txt` |
|
||||
| Claude CLI | `~\.local\bin\claude.exe` (native installer) |
|
||||
| Grok CLI | `~\.grok\bin\grok.exe` |
|
||||
| Gemini CLI | npm global (`@google/gemini-cli`) |
|
||||
|
||||
## Toolchain (as of 2026-06-06)
|
||||
|
||||
node 24.x · npm 11.x · py/Python 3.14 · git 2.53 · cargo/rustc 1.96 ·
|
||||
ollama 0.30.6 · jq 1.8 · sops 3.7→3.12 · age 1.3 · op 2.33 · VS Code 1.113 ·
|
||||
claude 2.1.x · gemini 0.45 · grok 0.2.x. **gh was missing** — bootstrap installs it.
|
||||
|
||||
Ollama models: `nomic-embed-text`, `qwen3:8b`, `qwen3:14b`, `codestral:22b`, `qwen3.6:latest`.
|
||||
|
||||
## Scheduled tasks (ClaudeTools)
|
||||
|
||||
- `GrepAI Watcher - claudetools` → `D:\claudetools\grepai.exe watch --background` (logon)
|
||||
- `ClaudeTools - Orphaned Session Detector` → `py detect_orphaned_sessions.py` (logon + daily)
|
||||
- `ClaudeTools - KSTEEN SmartBadge Daily` → git-bash `check-ksteen-smartbadge.sh` (daily)
|
||||
|
||||
## Capabilities
|
||||
|
||||
- [x] Git / Gitea, SSH to infra
|
||||
- [x] GrepAI watcher
|
||||
- [x] Ollama local AI (RTX 5070 Ti — light/inference OK)
|
||||
- [x] MCP: ticktick, grepai
|
||||
- [x] claude / gemini / grok CLIs (fleet host for all three)
|
||||
|
||||
## Recovery
|
||||
|
||||
Full rebuild after a reset: `.claude\bootstrap\RESTORE.md`.
|
||||
Recovery bundle on **E:** and **F:** (`\claudetools-recovery\`). Refresh it with
|
||||
`.claude\bootstrap\backup-to-bundle.ps1`.
|
||||
|
||||
## Known issues
|
||||
|
||||
- **Two Python interpreters, both must have deps.** `py` -> Python **3.14** (vault
|
||||
`yaml-query.py`/get-field needs PyYAML; helper + skill scripts; scheduled tasks).
|
||||
`python` -> Python **3.12** (the interpreter `.mcp.json` launches MCP servers with;
|
||||
ticktick needs `httpx` + `mcp`). The 2026-06-06 reinstall installed deps into only
|
||||
`py`, so ticktick MCP and `vault get-field` were both dead. `windows-bootstrap.ps1`
|
||||
Phase 7 now installs into BOTH interpreters. Also `websocket-client` (cdp.py) under `py`.
|
||||
- **Ollama models survive on `D:\OllamaModels` (~48 GB) but `ollama list` can read empty
|
||||
right after login** — the tray app's server takes a few seconds to hydrate its
|
||||
model-list cache. Don't treat empty as "models gone" / re-download. Restart the app
|
||||
(or `ollama serve` with `OLLAMA_MODELS=D:\OllamaModels`) and wait ~10s. Bootstrap
|
||||
Phase 8 handles this. The 5 expected models: nomic-embed-text, qwen3:8b, qwen3:14b,
|
||||
codestral:22b, qwen3.6:latest.
|
||||
- **grok CLI** is a bare `~\.grok\bin\grok.exe` drop; its installer doesn't touch PATH.
|
||||
Bootstrap Phase 3 now persists `~\.grok\bin` (+ `~\.local\bin`, `%APPDATA%\npm`) to User PATH.
|
||||
- **Git auth must be non-interactive** (no GCM password prompts — they hang automation).
|
||||
Primed by `.claude/scripts/setup-git-auth.sh` (vault token -> `store` helper, per-repo
|
||||
host) via a SessionStart hook + bootstrap Phase 6; `GIT_TERMINAL_PROMPT=0` is enforced
|
||||
in `.claude/settings.json`. See memory `feedback_git_noninteractive_auth`.
|
||||
- Old `D:\work\gururmm` remote URL embedded the shared Gitea password in plaintext —
|
||||
reset to a clean URL + Windows Credential Manager on rebuild.
|
||||
- (Hardware) RTX 5070 Ti GSP firmware bug under sustained GPU compute — see `acg-guru-5070.md`.
|
||||
@@ -1,100 +1,121 @@
|
||||
# Memory Index
|
||||
|
||||
## Reference
|
||||
- [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below.
|
||||
- [GURU-5070 Rust toolchain](reference_guru5070_rust_toolchain.md) — GURU-5070 now has cargo + MSVC + protoc; build/clippy/test guru-connect LOCALLY (set PROTOC to the winget path) instead of the build host. CI only clippy-checks the Linux server, not the Windows agent.
|
||||
- [ACG Office Network Infrastructure](infra_office_network.md) — IPs/hosts/roles for pfSense/Jupiter/VMs/Docker. Check before assuming; .21 (Uranus) is storage.
|
||||
- [Power Failure Runbook](../POWER_FAILURE_RUNBOOK.md) — Recovery order after a power event: Tailscale routes, libvirt/VMs, Seafile, NPM/DNS.
|
||||
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
|
||||
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval; features→roadmap, bugs→bug list.
|
||||
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
|
||||
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
|
||||
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
|
||||
- [Matomo Analytics](reference_matomo_analytics.md) — Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites.
|
||||
- [TickTick Integration](reference_ticktick_integration.md) — OAuth API integration, MCP server, SOPS vault creds, project/task CRUD.
|
||||
- [Client Docs Structure](reference_client_docs_structure.md) — clients/<name>/docs/ layout (overview, network, servers, cloud, security, rmm). Template: clients/_client_template/.
|
||||
- [MSP Audit Scripts](reference_msp_audit_scripts.md) — server_audit.ps1 / workstation_audit.ps1 at projects/msp-tools/msp-audit-scripts/.
|
||||
- [Pluto Build Server](reference_pluto_build_server.md) — Windows build VM: hostname PLUTO = Unraid VM "Claude-Builder" = 172.16.3.36 (all the same box). MSVC + WiX + Azure Trusted Signing. Drive via /rmm (agent enrolls as PLUTO) when SSH key isn't authorized.
|
||||
- [Coord /messages API shape](reference_coord_messages_api_shape.md) — GET /api/coord/messages returns {total,skip,limit,messages[]} NOT a bare array; parse .messages[], strip control chars, read flag may be null.
|
||||
- [Gitea API credential](reference_gitea_api_credential.md) — Gitea API (PRs/merges) as howard uses services/gitea-howard.sops.yaml password on internal http://172.16.3.20:3000; NOT the gururmm-server SSH password.
|
||||
- [Gitea Internal API Access](reference_gitea_internal.md) — git.azcomputerguru.com is NOT behind Cloudflare — it's the office Cox IP NAT'd to NPM (openresty) on Jupiter. Prefer internal 172.16.3.20:3000 for reliability (bypasses NPM SSL-renewal reload blips).
|
||||
- [Gitea git-op latency](reference_gitea_git_op_latency.md) — SSH (.20:2222) is SLOWEST (~1.5s); internal HTTP+token ~0.55s; SOPS lookup only ~0.33s. Don't switch to SSH for speed. Gitea SSH is .20:2222 (API ssh_url .21 is wrong).
|
||||
- [GuruRMM technical reference](reference_gururmm.md) — Server (172.16.3.30) layout + downloads dir `/var/www/gururmm/downloads` + `.channel` sidecar rollout control (stable/beta) + privileged server access via the server's OWN root RMM agent (hostname `gururmm`, no SSH needed; plink fallback) + API + `context=user_session` (WTS impersonation) + build-pipeline vendoring at `deploy/build-pipeline/` + Linux agent systemd sandbox trap.
|
||||
- [Trebesch DESKTOP-QNP3ON5 shell replacement](reference_trebesch_qnp3on5.md) — AT Trebesch box runs an Explorer shell replacement; explorer.exe owner check returns blank — use Win32_ComputerSystem.UserName. GuruRMM SWIFT-LION-2892.
|
||||
|
||||
## Users
|
||||
- [Howard Enos](user_howard.md) — Mike's brother, technician, full access. Machines: ACG-TECH03L, Howard-Home (authoritative in users.json).
|
||||
- [Mike — font preference](user_font_preference.md) — Mike prefers Lucida Console for monospace UI.
|
||||
|
||||
## Feedback
|
||||
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
|
||||
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
|
||||
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
|
||||
- [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) — Set permissions.defaultMode to bypassPermissions in settings.json on all machines.
|
||||
- [365 Remediation Tool](feedback_365_remediation_tool.md) — "remediation tool" = tiered ComputerGuru app suite via /remediation-tool; NOT CIPP, NOT the deprecated fabb3421.
|
||||
- [CA managed programmatically (with discipline)](feedback_ca_programmatic_management.md) — Conditional Access CAN be written via Tenant Admin app; ALWAYS report-only first + exclude break-glass + confirm before enforcing. Overrides old "CA manual" rule.
|
||||
- [Ollama Tier-0 Routing](feedback_ollama_tier0_routing.md) — Route drafts/summaries/classifications through Ollama (qwen3:14b). Mike designed ClaudeTools this way — not optional.
|
||||
- [/save writes narrative directly](feedback_save_no_ollama.md) — No Ollama for /save; write all sections inline — too slow.
|
||||
- [Identity precedence](feedback_identity_precedence.md) — Trust `.claude/identity.json` over the system-reminder `userEmail` hint when they disagree (shared-login machines).
|
||||
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
|
||||
- [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message.
|
||||
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
|
||||
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
|
||||
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
|
||||
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
|
||||
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
|
||||
- [Graph password reset needs a privileged role](feedback_graph_password_reset_requires_role.md) — PATCH passwordProfile on an existing user 403s without a directory role; User.ReadWrite.All alone only sets a password at CREATE.
|
||||
- [Vault writes — do the full sequence yourself](feedback_complete_vault_operations_end_to_end.md) — A vault entry = write plaintext → sops -e -i → git add/commit/push, all of it; don't stop at "encrypted on disk."
|
||||
- [Syncro is the default PSA; Autotask is opt-in](feedback_psa_default_syncro.md) — Ticketing/billing/customers default to Syncro (/syncro). Only use /autotask on an explicit "in Autotask" request. /autotask kept local/undistributed.
|
||||
- [Paste-safe command formatting (Howard)](feedback_command_formatting.md) — Two clauses, one root cause: (a) multi-line scripts not semicolon one-liners (wrap breaks paste), (b) all code at column 0 inside fences (indentation breaks PowerShell paste).
|
||||
- [Autonomous infra/build setup](feedback_autonomous_infra_setup.md) — During infra/build/CI/dev setup, just install prerequisites and push through routine steps; reserve check-ins for genuine decisions (forks, destructive/outward, client/prod).
|
||||
- [Check patterns before asking](feedback_check_patterns_before_asking.md) — Before asking how to do something repeat-style (sync, save, sweep, billing), study existing artifacts and workflow docs first; reach for similar past artifacts as the template.
|
||||
- [Pricing verification — no guessing](policy_pricing_verification.md) — ANY cost presented to the team or a client MUST be verified via live web lookup (WebFetch/WebSearch, fallback to headless Chrome). Never estimate from training data. Cite source + date inline. If unreachable, say so — do NOT substitute a guess.
|
||||
- [Client communication tone](feedback_client_tone.md) — How to write client-facing Syncro comments — expert partner, not intake questionnaire.
|
||||
- [Add Mike as owner on all Entra apps](feedback_entra_app_owner.md) — Apps created via management SP have no user owner — must add Mike manually or publisher verification fails.
|
||||
- [No TOML/config file approach for endpoints](feedback_no_toml_config_endpoints.md) — User explicitly prohibits TOML or config-file-based endpoint configuration — this will never be approved.
|
||||
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
|
||||
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
|
||||
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
|
||||
|
||||
### Syncro
|
||||
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).
|
||||
- [Syncro billing rules](feedback_syncro_billing.md) — Bill with `add_line_item` directly (not timers); fetch rates LIVE; never invent labor names (real product names only); match labor type to delivery channel (never "Prepaid project labor"); labor `taxable:false` (AZ); warranty `1049360` (never patch price); emergency `26184` ×1.5 once, branch by `prepay_hours`; corrections preserve original tech's user_id; estimate hardware `32252`.
|
||||
- [Syncro workflow rules](feedback_syncro_workflow.md) — ALWAYS preview comments before posting (no exceptions); verify appointment day-of-week ("Saturday 2026-05-23") before creating; ASK who the appointment owner is; leave `contact_id` BLANK by default for ALL customers (ignore Syncro's contact-picker auto-default).
|
||||
- [Syncro lessons / incident archive](feedback_syncro_history.md) — Detail behind the three rule files: tickets (#32332, #32312, #32225, #32253, #32203, #32185, #32142, #32304, #32333), verbatim Mike/Howard/Winter quotes, dates, tech user_id table (Mike 1735 / Howard 1750 / Winter 1737 / Rob 1760), labor product table, and superseded-rule history.
|
||||
|
||||
### GuruRMM
|
||||
- [GuruRMM operational rules](feedback_gururmm.md) — Six rules: (1) RMM dev = Mike, never Howard (368/0 commits); GuruScan is Howard's. (2) Agent parity Win+Linux+macOS in same change. (3) Builds via Gitea webhook pipeline only, never SSH. (4) #bot-alerts only for client/ticket impact, skip internal infra/dev. (5) Identify agents by IP, not by reconning candidates. (6) UNC paths in user_session need [char]92 — literals get halved.
|
||||
- [Build channel default = beta](feedback_gururmm_build_channel_default.md) — New agent builds must be tagged BETA by default (stable = explicit promote re-tag); distinct from agents defaulting to the stable CHANNEL (correct). Fixed build-windows/linux.sh 2026-06-01; macOS already correct. Enables beta-first canary.
|
||||
- [Dashboard beta-first deploy](feedback_dashboard_beta_first.md) — Dashboard auto-builds to rmm-beta.azcomputerguru.com on push; prod (rmm.azcomputerguru.com) is explicit promote-only via promote-dashboard.sh --confirm. Never hand-rsync prod. One artifact, nginx sub_filter BETA banner. Stood up 2026-06-02.
|
||||
|
||||
### Cascades
|
||||
- [Cascades operational rules](feedback_cascades.md) — Two active rules: (1) folder redirection (fdeploy) needs subfolders PRE-CREATED before first logon or it caches a failure forever; recovery via fix-shell-redirect.ps1. (2) ALWAYS ask which security group(s) a new user goes into — never auto-derive from OU.
|
||||
|
||||
## Machine
|
||||
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
|
||||
- [GURU-BEAST-ROG Setup Status](machine_windows_guru_setup_status.md) — Windows workstation fully configured except SSH key deployment to servers.
|
||||
|
||||
## Project
|
||||
- [Automate memory consolidation/lint (phased)](project_memory_consolidation_automation.md) — Eventually auto-run /memory-dream; lint+additive fixes can automate early, merges/deletes stay human-approved. Engine: .claude/skills/memory-dream/ + .claude/scripts/sync-memory.sh.
|
||||
- [Trebesch PST consolidation (staged)](project_trebesch_pst_consolidation.md) — Address-book CSV from 24 PSTs on DESKTOP-QNP3ON5; scripts staged at .claude/tmp/treb-*.ps1, WAITING for Howard's 6pm-MST 2026-06-01 go signal (attended run). See [[reference_trebesch_qnp3on5]].
|
||||
- [GuruRMM project state](project_gururmm.md) — Dev principles (every feature full-stack: backend+API+UI+docs+scalability; product works without AI; FEATURE_ROADMAP update is part of definition-of-done; mirrors guru-rmm/docs/DESIGN.md). Webhook docs-only build guard (SPEC-020 Phase 0; webhook-handler.py repo copy is STALE — don't redeploy). Mac install-hooks.sh setup STILL PENDING on Mikes-MacBook-Air.
|
||||
- [GuruConnect](project_guruconnect.md) — v2 direction (native-first full key fidelity Win+R/Ctrl+Alt+Del + bidirectional file cut/paste/drag; WebRTC fallback only; standalone-first + RMM contract; tenancy-ready schema; Mike willing to scrap v1). Manual deploy procedure to 172.16.3.30 (build-on-server in login shell; sqlx runtime queries; NPM `CONNECT_TRUSTED_PROXIES=172.16.3.20` gotcha). v2 live since 2026-05-30.
|
||||
- [Apple MDM + Developer certs (GuruRMM mobile)](project_apple_mdm_certs.md) — ACG holds Apple Developer+signing and Apple MDM Push certs (acquired 2026-05-29) for SPEC-017. MDM push cert RENEWS ANNUALLY on the same Apple ID or all enrolled iOS devices break.
|
||||
- [Only RMM & GC are versionable products](project_versionable_products.md) — GuruRMM + GuruConnect are the only products with own repos/submodules; everything else stays in the claudetools monorepo. Split only for independent pipeline OR versioned external consumer.
|
||||
- [Quantum GoDaddy M365 tenant](project_quantum_godaddy_m365_tenant.md) — quantumwms.com parked in a GoDaddy-provisioned M365 tenant (id ddf3d2c9-b76c-40d9-a216-9f11a1a26f97, netorg18235235.onmicrosoft.com); blocks Pax8 migration until GoDaddy removed.
|
||||
- [Cascades](project_cascades.md) — Active state: Syncro ticket #110680053 + plan file (machine-specific path on Howard's box), admin accounts (sysadmin@=Howard, admin@=Mike — daily-driver, NOT break-glass), Phase-B caregiver CA pilot (SG-Caregivers-Pilot, group-scoped never tenant-wide), prepaid block ~37.5h (rate TBD), pilot cleanup checklist.
|
||||
- [Cascades history](project_cascades_history.md) — fdeploy 502/ACL root cause (Flags=1211→187 fix), 2026-04-29 CA-rescoping decision (Howard pulled the brakes on tenant-wide), 2026-05-14 per-user-security-group decision rationale.
|
||||
- [Sync script bug — untracked files (RESOLVED)](project_sync_script_bug.md) — FIXED 2026-05-21: sync.sh now uses `git status --porcelain` for change detection (repo + vault).
|
||||
- [MasterBooter Side Project](project_masterbooter.md) — Howard's Rust+Slint Windows deployment toolkit at C:\MasterBooter, separate from client work. Do not log to clients/.
|
||||
- [Audio Processor Architecture](project_audio_processor_architecture.md) — Segment-first pipeline: detect breaks before transcription for complete content capture.
|
||||
- [Neptune SBR Email Routing Setup](project_neptune_sbr_email_routing.md) — Full SBR routing chain, config file locations, MailProtector integration, access methods. Treat routing breakage as systemic (devcon, Sorensen/rieussetcorp), not per-client.
|
||||
- [Dataforth Test Datasheet Pipeline](project_datasheet_pipeline.md) — Full pipeline rebuilt 2026-03-27. Server-side generation replaces DFWDS/Uploader. Website upload still broken.
|
||||
- [Dataforth](project_dataforth.md) — M365 email (Graph API; tenant in vault at clients/dataforth/m365.sops.yaml); neptune.acghosting.com is ACG's, NOT Dataforth's. MFA enforced 2026-04-04 (3 CA policies). AJ needs dataforthgit@ forwarding.
|
||||
- [Dataforth history (2026-03-27 incident)](project_dataforth_history.md) — DF-JOEL2 compromise via ScreenConnect social-engineering, attacker C2 IPs + IC3 case + remediation log + MFA rollout origin story + Joel Lohr retirement. RESOLVED 2026-04-04.
|
||||
- [Radio show co-host — Tara, not Tom](radio_show_no_cohost_named_tom.md) — Co-host in 2014-s6e19 and 2016-s8e43 is Tara. "Tom" was hallucinated; rename complete.
|
||||
- [Proposal: centralize config in identity.json](proposal_identity_centralization.md) — Rationale for the identity.json machine-config centralization (claudetools_root, ollama/python); now implemented.
|
||||
- [ACG MSP tool stack](reference_acg_msp_stack.md) — ScreenConnect/CW Control, Splashtop, Syncro, Datto RMM, Datto EDR/AV, GuruRMM are ACG's OWN tools; do not flag as foreign/threat on managed machines (Defender-off is expected when Datto AV is active).
|
||||
- [ACG Website Hosting](project_azcomputerguru_hosting.md) — azcomputerguru.com is hosted on IX Web Hosting via cPanel.
|
||||
- [jq on Windows emits CRLF](feedback_jq_crlf_windows.md) — winget jq outputs CRLF; trailing \r silently breaks `for x in $(jq ...)` loops + read-from-@tsv. Override `jq(){ command jq "$@"|tr -d '\r'; }`. Windows-build-specific (passes on Mac/Linux).
|
||||
- [ScreenConnect RESTful API auth](reference_screenconnect_api.md) — CTRLAuthHeader = raw api_secret (no Basic/b64) + Origin header; only method is GetSessionsByName; matches blank-for-agents Name field so it cannot enumerate full inventory.
|
||||
# Memory Index
|
||||
|
||||
## Reference
|
||||
- [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below.
|
||||
- [GURU-5070 Rust toolchain](reference_guru5070_rust_toolchain.md) — GURU-5070 now has cargo + MSVC + protoc; build/clippy/test guru-connect LOCALLY (set PROTOC to the winget path) instead of the build host. CI only clippy-checks the Linux server, not the Windows agent.
|
||||
- [ACG Office Network Infrastructure](infra_office_network.md) — IPs/hosts/roles for pfSense/Jupiter/VMs/Docker. Check before assuming; .21 (Uranus) is storage.
|
||||
- [Power Failure Runbook](../POWER_FAILURE_RUNBOOK.md) — Recovery order after a power event: Tailscale routes, libvirt/VMs, Seafile, NPM/DNS.
|
||||
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
|
||||
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval; features→roadmap, bugs→bug list.
|
||||
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
|
||||
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.
|
||||
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
|
||||
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
|
||||
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
|
||||
- [Matomo Analytics](reference_matomo_analytics.md) — Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites.
|
||||
- [TickTick Integration](reference_ticktick_integration.md) — OAuth API integration, MCP server, SOPS vault creds, project/task CRUD.
|
||||
- [Client Docs Structure](reference_client_docs_structure.md) — clients/<name>/docs/ layout (overview, network, servers, cloud, security, rmm). Template: clients/_client_template/.
|
||||
- [MSP Audit Scripts](reference_msp_audit_scripts.md) — server_audit.ps1 / workstation_audit.ps1 at projects/msp-tools/msp-audit-scripts/.
|
||||
- [Pluto Build Server](reference_pluto_build_server.md) — Windows build VM: hostname PLUTO = Unraid VM "Claude-Builder" = 172.16.3.36 (all the same box). MSVC + WiX + Azure Trusted Signing. Drive via /rmm (agent enrolls as PLUTO) when SSH key isn't authorized.
|
||||
- [Coord /messages API shape](reference_coord_messages_api_shape.md) — GET /api/coord/messages returns {total,skip,limit,messages[]} NOT a bare array; parse .messages[], strip control chars, read flag may be null.
|
||||
- [Gitea API credential](reference_gitea_api_credential.md) — Gitea API (PRs/merges) as howard uses services/gitea-howard.sops.yaml password on internal http://172.16.3.20:3000; NOT the gururmm-server SSH password.
|
||||
- [Gitea Internal API Access](reference_gitea_internal.md) — git.azcomputerguru.com is NOT behind Cloudflare — it's the office Cox IP NAT'd to NPM (openresty) on Jupiter. Prefer internal 172.16.3.20:3000 for reliability (bypasses NPM SSL-renewal reload blips).
|
||||
- [Gitea git-op latency](reference_gitea_git_op_latency.md) — SSH (.20:2222) is SLOWEST (~1.5s); internal HTTP+token ~0.55s; SOPS lookup only ~0.33s. Don't switch to SSH for speed. Gitea SSH is .20:2222 (API ssh_url .21 is wrong).
|
||||
- [GuruRMM technical reference](reference_gururmm.md) — Server (172.16.3.30) layout + downloads dir `/var/www/gururmm/downloads` + `.channel` sidecar rollout control (stable/beta) + privileged server access via the server's OWN root RMM agent (hostname `gururmm`, no SSH needed; plink fallback) + API + `context=user_session` (WTS impersonation) + build-pipeline vendoring at `deploy/build-pipeline/` + Linux agent systemd sandbox trap.
|
||||
- [Trebesch DESKTOP-QNP3ON5 shell replacement](reference_trebesch_qnp3on5.md) — AT Trebesch box runs an Explorer shell replacement; explorer.exe owner check returns blank — use Win32_ComputerSystem.UserName. GuruRMM SWIFT-LION-2892.
|
||||
|
||||
## Users
|
||||
- [Howard Enos](user_howard.md) — Mike's brother, technician, full access. Machines: ACG-TECH03L, Howard-Home (authoritative in users.json).
|
||||
- [Mike — font preference](user_font_preference.md) — Mike prefers Lucida Console for monospace UI.
|
||||
|
||||
## Feedback
|
||||
- [Bot alerts need a ticket link](feedback_bot_alert_ticket_link.md) — Syncro ticket bot-alerts MUST include a clickable link: https://computerguru.syncromsp.com/tickets/<internal_id> (internal id, not ticket number). post-bot-alert.sh posts raw text; put the URL in the message.
|
||||
- [Mac RMM authentication fixed](feedback_mac_rmm_auth_fixed.md) — Use `.claude/scripts/rmm-auth.sh` helper instead of heredoc pattern. Heredoc with `--data-binary @-` fails on macOS. Helper uses `jq -n --arg` to build JSON safely. Usage: `eval "$(bash .claude/scripts/rmm-auth.sh)"` sets $TOKEN, $RMM, $REPO_ROOT. Updated in /rmm Phase 0.
|
||||
- [Verify committed state before push](feedback_verify_committed_state_before_push.md) — webhook builds from origin/main: verify the COMMITTED build (git stash + build), not the working tree; bad git-add pathspec silently aborts staging. Stage by directory.
|
||||
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
|
||||
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
|
||||
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
|
||||
- [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) — Set permissions.defaultMode to bypassPermissions in settings.json on all machines.
|
||||
- [365 Remediation Tool](feedback_365_remediation_tool.md) — "remediation tool" = tiered ComputerGuru app suite via /remediation-tool; NOT CIPP, NOT the deprecated fabb3421.
|
||||
- [CA managed programmatically (with discipline)](feedback_ca_programmatic_management.md) — Conditional Access CAN be written via Tenant Admin app; ALWAYS report-only first + exclude break-glass + confirm before enforcing. Overrides old "CA manual" rule.
|
||||
- [Ollama Tier-0 Routing](feedback_ollama_tier0_routing.md) — Route drafts/summaries/classifications through Ollama (qwen3:14b). Mike designed ClaudeTools this way — not optional.
|
||||
- [/save writes narrative directly](feedback_save_no_ollama.md) — No Ollama for /save; write all sections inline — too slow.
|
||||
- [Identity precedence](feedback_identity_precedence.md) — Trust `.claude/identity.json` over the system-reminder `userEmail` hint when they disagree (shared-login machines).
|
||||
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
|
||||
- [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message.
|
||||
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
|
||||
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
|
||||
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
|
||||
- [Vault git auth — GCM shadows store token](feedback_vault_gcm_shadow_auth.md) — vault sync "Failed to authenticate user" on git.azcomputerguru.com: GCM is first in the helper chain and shadows the valid store token. Fix (machine-local): store-only credential.helper reset + pin `azcomputerguru@` in the vault remote URL so store returns the durable PAT (not the volatile OAUTH_USER JWT). Applied GURU-5070 2026-06-07.
|
||||
- [Antigravity agy.exe is not a headless CLI](reference_antigravity_agy_not_headless.md) — the `agy` skill's real backend is `@google/gemini-cli`, not the Antigravity `agy.exe` (IDE agent, no stdout, hangs). Don't reinstall agy.exe expecting headless output. Mike has a paid Gemini account, so stay on gemini-cli past the June 18 free-tier sunset (prefer `GEMINI_API_KEY`).
|
||||
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
|
||||
- [RMM password setting limitation](feedback_rmm_password_limitation.md) — `net user <user> <password>` via GuruRMM fails silently (exit 0 but password doesn't set). Tested PowerShell AND CMD - both fail. ScreenConnect CMD works (also as SYSTEM). GuruRMM agent bug in process spawning. Use ScreenConnect for password ops. HIGH priority to fix.
|
||||
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
|
||||
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
|
||||
- [Graph password reset needs a privileged role](feedback_graph_password_reset_requires_role.md) — PATCH passwordProfile on an existing user 403s without a directory role; User.ReadWrite.All alone only sets a password at CREATE.
|
||||
- [Vault writes — do the full sequence yourself](feedback_complete_vault_operations_end_to_end.md) — A vault entry = write plaintext → sops -e -i → git add/commit/push, all of it; don't stop at "encrypted on disk."
|
||||
- [Exchange role recurring gap — backfill, don't promise](feedback_exchange_role_recurring_gap.md) — EXO email-cleanup 401/403 = Exchange Operator SP missing the Exchange Admin directory role (consent never grants it). Fix: `assign-exchange-role.sh <domain|--all>` (idempotent); audit with `--all --verify`. Fleet backfilled 2026-06-08. Verify membership via roleManagement/directory/roleAssignments (not the laggy directoryRoles/members list); EXO propagation 15-60min.
|
||||
- [Syncro is the default PSA; Autotask is opt-in](feedback_psa_default_syncro.md) — Ticketing/billing/customers default to Syncro (/syncro). Only use /autotask on an explicit "in Autotask" request. /autotask kept local/undistributed.
|
||||
- [Paste-safe command formatting (Howard)](feedback_command_formatting.md) — Two clauses, one root cause: (a) multi-line scripts not semicolon one-liners (wrap breaks paste), (b) all code at column 0 inside fences (indentation breaks PowerShell paste).
|
||||
- [Autonomous infra/build setup](feedback_autonomous_infra_setup.md) — During infra/build/CI/dev setup, just install prerequisites and push through routine steps; reserve check-ins for genuine decisions (forks, destructive/outward, client/prod).
|
||||
- [Check patterns before asking](feedback_check_patterns_before_asking.md) — Before asking how to do something repeat-style (sync, save, sweep, billing), study existing artifacts and workflow docs first; reach for similar past artifacts as the template.
|
||||
- [Cascades scan-to-folder uses svc-scan](feedback_cascades_scan_account.md) — Every scanner->network-folder setup at Cascades reuses the one `svc-scan` AD service account (NTLMv2, vaulted); never make a per-printer scan account.
|
||||
- [Calibrate effort to stakes](feedback_calibrate_effort_to_stakes.md) — Don't over-verify or over-engineer low-consequence details; confirm the happy path, note the limitation, and take the simplest path (e.g. put the instruction in the prompt) instead of building robust mechanisms.
|
||||
- [Pricing verification — no guessing](policy_pricing_verification.md) — ANY cost presented to the team or a client MUST be verified via live web lookup (WebFetch/WebSearch, fallback to headless Chrome). Never estimate from training data. Cite source + date inline. If unreachable, say so — do NOT substitute a guess.
|
||||
- [Client communication tone](feedback_client_tone.md) — How to write client-facing Syncro comments — expert partner, not intake questionnaire.
|
||||
- [Default to inline links](feedback_inline_links.md) — Use `[text](url)` inline markdown links (clickable, wrap-safe) not bare URLs in code fences; exception = raw URL the user must copy/paste.
|
||||
- [Add Mike as owner on all Entra apps](feedback_entra_app_owner.md) — Apps created via management SP have no user owner — must add Mike manually or publisher verification fails.
|
||||
- [No TOML/config file approach for endpoints](feedback_no_toml_config_endpoints.md) — User explicitly prohibits TOML or config-file-based endpoint configuration — this will never be approved.
|
||||
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
|
||||
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
|
||||
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
|
||||
- [agy review is not read-only](feedback_agy_review_not_readonly.md) — agy review/review-files CAN write files + run npm despite docs claiming plan-mode; always git diff after and treat Gemini's output as a proposal to validate, not trusted/finished work.
|
||||
|
||||
### Syncro
|
||||
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).
|
||||
- [Syncro billing rules](feedback_syncro_billing.md) — Bill with `add_line_item` directly (not timers); fetch rates LIVE; never invent labor names (real product names only); match labor type to delivery channel (never "Prepaid project labor"); labor `taxable:false` (AZ); warranty `1049360` (never patch price); emergency `26184` ×1.5 once, branch by `prepay_hours`; corrections preserve original tech's user_id; estimate hardware `32252`.
|
||||
- [Syncro workflow rules](feedback_syncro_workflow.md) — ALWAYS preview comments before posting (no exceptions); verify appointment day-of-week ("Saturday 2026-05-23") before creating; ASK who the appointment owner is; leave `contact_id` BLANK by default for ALL customers (ignore Syncro's contact-picker auto-default).
|
||||
- [Syncro lessons / incident archive](feedback_syncro_history.md) — Detail behind the three rule files: tickets (#32332, #32312, #32225, #32253, #32203, #32185, #32142, #32304, #32333), verbatim Mike/Howard/Winter quotes, dates, tech user_id table (Mike 1735 / Howard 1750 / Winter 1737 / Rob 1760), labor product table, and superseded-rule history.
|
||||
|
||||
### GuruRMM
|
||||
- [GuruRMM operational rules](feedback_gururmm.md) — Six rules: (1) RMM dev = Mike, never Howard (368/0 commits); GuruScan is Howard's. (2) Agent parity Win+Linux+macOS in same change. (3) Builds via Gitea webhook pipeline only, never SSH. (4) #bot-alerts only for client/ticket impact, skip internal infra/dev. (5) Identify agents by IP, not by reconning candidates. (6) UNC paths in user_session need [char]92 — literals get halved.
|
||||
- [Build channel default = beta](feedback_gururmm_build_channel_default.md) — New agent builds must be tagged BETA by default (stable = explicit promote re-tag); distinct from agents defaulting to the stable CHANNEL (correct). Fixed build-windows/linux.sh 2026-06-01; macOS already correct. Enables beta-first canary.
|
||||
- [Dashboard beta-first deploy](feedback_dashboard_beta_first.md) — Dashboard auto-builds to rmm-beta.azcomputerguru.com on push; prod (rmm.azcomputerguru.com) is explicit promote-only via promote-dashboard.sh --confirm. Never hand-rsync prod. One artifact, nginx sub_filter BETA banner. Stood up 2026-06-02.
|
||||
|
||||
### Cascades
|
||||
- [Cascades operational rules](feedback_cascades.md) — Two active rules: (1) folder redirection (fdeploy) needs subfolders PRE-CREATED before first logon or it caches a failure forever; recovery via fix-shell-redirect.ps1. (2) ALWAYS ask which security group(s) a new user goes into — never auto-derive from OU.
|
||||
- [Cascades FR GPO fix](reference_cascades_fr_gpo_fix.md) — Native Folder Redirection was DOA on every machine: redirect targets were in a misnamed `fdeploy1.ini` (Windows reads `fdeploy.ini`) → empty target path → silent no-op → per-user registry workaround every time. Fixed 2026-06-08 (correct fdeploy.ini + version bump). Also: CS-SERVER live RMM agent is `c39f1de7...` (old `6766e973` stale).
|
||||
|
||||
## Machine
|
||||
- [GURU-5070 Workstation Setup](reference_workstation_setup.md) — Mike's primary (owner confirmed 2026-05-26). Windows 11 Pro. Renamed from OC-5070 → ACG-5070/acg-guru-5070 → GURU-5070; all the same box, all Mike's.
|
||||
- [GURU-BEAST-ROG Setup Status](machine_windows_guru_setup_status.md) — Windows workstation fully configured except SSH key deployment to servers.
|
||||
|
||||
## Project
|
||||
- [Automate memory consolidation/lint (phased)](project_memory_consolidation_automation.md) — Eventually auto-run /memory-dream; lint+additive fixes can automate early, merges/deletes stay human-approved. Engine: .claude/skills/memory-dream/ + .claude/scripts/sync-memory.sh.
|
||||
- [Trebesch PST consolidation (staged)](project_trebesch_pst_consolidation.md) — Address-book CSV from 24 PSTs on DESKTOP-QNP3ON5; scripts staged at .claude/tmp/treb-*.ps1, WAITING for Howard's 6pm-MST 2026-06-01 go signal (attended run). See [[reference_trebesch_qnp3on5]].
|
||||
- [GuruRMM project state](project_gururmm.md) — Dev principles (every feature full-stack: backend+API+UI+docs+scalability; product works without AI; FEATURE_ROADMAP update is part of definition-of-done; mirrors guru-rmm/docs/DESIGN.md). Webhook docs-only build guard (SPEC-020 Phase 0; webhook-handler.py repo copy is STALE — don't redeploy). Mac install-hooks.sh setup STILL PENDING on Mikes-MacBook-Air.
|
||||
- [GuruConnect](project_guruconnect.md) — v2 direction (native-first full key fidelity Win+R/Ctrl+Alt+Del + bidirectional file cut/paste/drag; WebRTC fallback only; standalone-first + RMM contract; tenancy-ready schema; Mike willing to scrap v1). Manual deploy procedure to 172.16.3.30 (build-on-server in login shell; sqlx runtime queries; NPM `CONNECT_TRUSTED_PROXIES=172.16.3.20` gotcha). v2 live since 2026-05-30.
|
||||
- [Apple MDM + Developer certs (GuruRMM mobile)](project_apple_mdm_certs.md) — ACG holds Apple Developer+signing and Apple MDM Push certs (acquired 2026-05-29) for SPEC-017. MDM push cert RENEWS ANNUALLY on the same Apple ID or all enrolled iOS devices break.
|
||||
- [Only RMM & GC are versionable products](project_versionable_products.md) — GuruRMM + GuruConnect are the only products with own repos/submodules; everything else stays in the claudetools monorepo. Split only for independent pipeline OR versioned external consumer.
|
||||
- [Quantum GoDaddy M365 tenant](project_quantum_godaddy_m365_tenant.md) — quantumwms.com parked in a GoDaddy-provisioned M365 tenant (id ddf3d2c9-b76c-40d9-a216-9f11a1a26f97, netorg18235235.onmicrosoft.com); blocks Pax8 migration until GoDaddy removed.
|
||||
- [Cascades](project_cascades.md) — Active state: Syncro ticket #110680053 + plan file (machine-specific path on Howard's box), admin accounts (sysadmin@=Howard, admin@=Mike — daily-driver, NOT break-glass), Phase-B caregiver CA pilot (SG-Caregivers-Pilot, group-scoped never tenant-wide), prepaid block ~37.5h (rate TBD), pilot cleanup checklist.
|
||||
- [Cascades history](project_cascades_history.md) — fdeploy 502/ACL root cause (Flags=1211→187 fix), 2026-04-29 CA-rescoping decision (Howard pulled the brakes on tenant-wide), 2026-05-14 per-user-security-group decision rationale.
|
||||
- [Sync script bug — untracked files (RESOLVED)](project_sync_script_bug.md) — FIXED 2026-05-21: sync.sh now uses `git status --porcelain` for change detection (repo + vault).
|
||||
- [MasterBooter Side Project](project_masterbooter.md) — Howard's Rust+Slint Windows deployment toolkit at C:\MasterBooter, separate from client work. Do not log to clients/.
|
||||
- [Audio Processor Architecture](project_audio_processor_architecture.md) — Segment-first pipeline: detect breaks before transcription for complete content capture.
|
||||
- [Neptune SBR Email Routing Setup](project_neptune_sbr_email_routing.md) — Full SBR routing chain, config file locations, MailProtector integration, access methods. Treat routing breakage as systemic (devcon, Sorensen/rieussetcorp), not per-client.
|
||||
- [Dataforth Test Datasheet Pipeline](project_datasheet_pipeline.md) — Full pipeline rebuilt 2026-03-27. Server-side generation replaces DFWDS/Uploader. Website upload still broken.
|
||||
- [Dataforth](project_dataforth.md) — M365 email (Graph API; tenant in vault at clients/dataforth/m365.sops.yaml); neptune.acghosting.com is ACG's, NOT Dataforth's. MFA enforced 2026-04-04 (3 CA policies). AJ needs dataforthgit@ forwarding.
|
||||
- [Dataforth history (2026-03-27 incident)](project_dataforth_history.md) — DF-JOEL2 compromise via ScreenConnect social-engineering, attacker C2 IPs + IC3 case + remediation log + MFA rollout origin story + Joel Lohr retirement. RESOLVED 2026-04-04.
|
||||
- [Radio show co-host — Tara, not Tom](radio_show_no_cohost_named_tom.md) — Co-host in 2014-s6e19 and 2016-s8e43 is Tara. "Tom" was hallucinated; rename complete.
|
||||
- [Proposal: centralize config in identity.json](proposal_identity_centralization.md) — Rationale for the identity.json machine-config centralization (claudetools_root, ollama/python); now implemented.
|
||||
- [ACG MSP tool stack](reference_acg_msp_stack.md) — ScreenConnect/CW Control, Splashtop, Syncro, Datto RMM, Datto EDR/AV, GuruRMM are ACG's OWN tools; do not flag as foreign/threat on managed machines (Defender-off is expected when Datto AV is active).
|
||||
- [ACG Website Hosting](project_azcomputerguru_hosting.md) — azcomputerguru.com is hosted on IX Web Hosting via cPanel.
|
||||
- [jq on Windows emits CRLF](feedback_jq_crlf_windows.md) — winget jq outputs CRLF; trailing \r silently breaks `for x in $(jq ...)` loops + read-from-@tsv. Override `jq(){ command jq "$@"|tr -d '\r'; }`. Windows-build-specific (passes on Mac/Linux).
|
||||
- [ScreenConnect RESTful API auth](reference_screenconnect_api.md) — CTRLAuthHeader = raw api_secret (no Basic/b64) + Origin header; only method is GetSessionsByName; matches blank-for-agents Name field so it cannot enumerate full inventory.
|
||||
- [No manufactured guardrails on our products](feedback_no_manufactured_guardrails.md) — At Mikes request on GuruRMM/GuruConnect/ClaudeTools, just execute; stop only for genuinely irreversible/destructive ops (with a heads-up). Read the actual code/state before claiming something is disallowed or a security hole.
|
||||
- [Stream-of-thought design convos](feedback_stream_of_thought_design.md) — Mike brainstorms features free-form, adding requirements iteratively; Claude validates/sharpens as a design partner but does NOT build until an explicit go, then captures parked threads durably (PARKED_*.md + todos) for a later /shape-spec.
|
||||
- [RMM Thoughts backlog](feedback_rmm_thoughts_backlog.md) — GuruRMM ideas from Mike & Howard go in projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md (Status: Raw); pipeline thought -> discuss -> spec (/shape-spec) -> roadmap. Don't build until an explicit go.
|
||||
- [Syncro preview mandatory](feedback_syncro_preview_mandatory.md) — preview+confirm every Syncro write, including internal notes
|
||||
- [Refresh session history first](feedback_refresh_session_history_first.md) — read prior incident logs before acting; do not re-remediate already-handled accounts
|
||||
- [Autonomy scope](feedback_autonomy_scope.md) — confirm only for client-affecting actions; internal docs/wiki/ClaudeTools = act autonomously
|
||||
|
||||
@@ -24,8 +24,13 @@ Graph API permissions alone are NOT sufficient for privileged operations. The se
|
||||
**Roles assigned so far:**
|
||||
- Valleywide Plastering (5c53ae9f...): User Administrator
|
||||
- Dataforth (7dfa3ce8...): User Administrator, Exchange Administrator
|
||||
- azcomputerguru.com (ce61461e...): full set assigned 2026-06-05 — Sec-Inv + Exch-Op = Exchange Administrator; Tenant Admin = Conditional Access Administrator; User Manager = User Administrator + Authentication Administrator.
|
||||
|
||||
**For new tenants:** After admin consent, manually assign roles via Entra portal > Roles and administrators. The app cannot self-assign directory roles.
|
||||
**For new tenants:** `onboard-tenant.sh <domain>` assigns the directory roles programmatically (Tenant Admin tier) — no manual portal step needed. The app cannot self-assign; the Tenant Admin SP does it.
|
||||
|
||||
**GOTCHA — pre-2026-04-20 tenants have NO directory roles.** The directory-role assignment block was added to `onboard-tenant.sh` in commit cd50117a on **2026-04-20**. Before that, "onboarding" only did app consent + Graph/EXO API permissions. So any tenant onboarded before that date has full app permissions but **zero directory role assignments** — Graph reads work, but **Exchange REST (quarantine, Get-Mailbox, message trace) and other privileged ops 401** until you re-run `onboard-tenant.sh`. This is NOT a removal/breach — the roles were simply never assigned, and with no Entra ID P2 there's no PIM to auto-expire anything. ACG's own tenant hit exactly this on 2026-06-05 (EOP quarantine check 401'd). **Re-run `onboard-tenant.sh` on any tenant onboarded before 2026-04-20** — Valleywide, Dataforth, Cascades are prime candidates to verify proactively. Confirm actual state with `roleManagement/directory/roleAssignments?$filter=principalId%20eq%20'<sp-oid>'&$expand=roleDefinition` (tenant-admin token; classic endpoint, no P2 needed — the PIM `roleAssignmentSchedules` endpoints return `AadPremiumLicenseRequired` without P2).
|
||||
|
||||
**BUG (fixed 2026-06-05):** `onboard-tenant.sh role_assigned()` had an unencoded space in its `$filter` (`principalId eq '...'`), so the query always failed → function always returned false → script always printed "MISSING -> ASSIGNING" and leaned on the conflict-tolerant POST for idempotency (assignment still worked, but PRESENT/MISSING reporting was meaningless). Fixed to `%20`. The old TODO blaming PIM was a misdiagnosis.
|
||||
|
||||
### Exchange Online REST API
|
||||
|
||||
|
||||
12
.claude/memory/feedback_agy_review_not_readonly.md
Normal file
12
.claude/memory/feedback_agy_review_not_readonly.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: feedback-agy-review-not-readonly
|
||||
description: agy review/review-files can actually WRITE files + run npm, despite docs claiming read-only plan mode — review Gemini's diffs, don't trust its summary.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
The `agy` SKILL.md documents `review` / `review-files` as read-only (`--approval-mode plan`: "Gemini can read files but cannot modify anything"). Observed 2026-06-05 on GURU-5070: a `review-files` call asking Gemini to "improve" the human-flow skill resulted in Gemini **actually editing 6 repo files, adding babel deps to package.json, and running npm install** (created package-lock.json + node_modules). So plan-mode was NOT enforced for that run.
|
||||
|
||||
**Why:** The documented safety contract (read-only review) cannot be relied on. Gemini also over-claims — its final summary said it "delivered/upgraded" the skill as if complete, but the only way to know what truly happened was to `git diff` and run the code.
|
||||
|
||||
**How to apply:** After ANY `agy review*` call, `git status` / `git diff` the target tree to see what actually changed — never trust the summary. If you need a guaranteed read-only second opinion, copy targets to a scratch dir first, or verify the wrapper's approval-mode. The improvements may be good, but they are a PROPOSAL to review and validate (run it, check repo rules like NO EMOJIS), not trusted output. Related: [[reference_gitea_internal]] is unrelated; see agy SKILL.md path gotcha.
|
||||
12
.claude/memory/feedback_autonomy_scope.md
Normal file
12
.claude/memory/feedback_autonomy_scope.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: feedback_autonomy_scope
|
||||
description: Confirm-before-acting applies ONLY to client-affecting actions; internal docs/wiki/memory/ClaudeTools are trusted — act autonomously.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
The "preview / ask before acting" discipline is scoped to actions that **affect a client directly** — Syncro writes (tickets/comments/billing), customer emails, and changes to a client's M365/infra (password resets, session revokes, MFA/CA changes, domain blocks, mailbox changes). Those get a payload preview + Mike's explicit confirmation.
|
||||
|
||||
**Internal documentation and anything within ClaudeTools — wiki articles, memory, session logs, repo housekeeping, consolidating/redirecting wiki pages — is trusted: just do it, no asking.** Mike (2026-06-09): "The ask before is only for things that will affect a client directly. I trust you to manage internal documentation and within claudetools."
|
||||
|
||||
**Why:** asking permission for internal repo/wiki edits is friction with no upside; the guardrail exists for irreversible client-facing actions. See [[feedback_syncro_preview_mandatory]] and [[feedback_refresh_session_history_first]] (those remain correct — they're about client-facing writes).
|
||||
22
.claude/memory/feedback_bot_alert_ticket_link.md
Normal file
22
.claude/memory/feedback_bot_alert_ticket_link.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: feedback_bot_alert_ticket_link
|
||||
description: Syncro/ticket bot alerts must include a clickable link to the ticket
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Every `#bot-alerts` post about a Syncro ticket MUST include a clickable link to that ticket.
|
||||
`post-bot-alert.sh` posts the raw message verbatim — it does NOT auto-append a link — so the URL
|
||||
must be in the message text. Discord auto-links bare URLs.
|
||||
|
||||
**Why:** Mike wants to click straight through to the ticket from the alert feed; an alert without
|
||||
the link makes him hunt for it (flagged 2026-06-05 on Bardach #32387).
|
||||
|
||||
**How to apply:**
|
||||
- Syncro ticket URL uses the **internal ticket id**, NOT the ticket number:
|
||||
`https://computerguru.syncromsp.com/tickets/<internal_id>` (e.g. #32387 -> id 112248434 ->
|
||||
`https://computerguru.syncromsp.com/tickets/112248434`).
|
||||
- Put the URL on its own line after the summary, or inline. To edit an already-posted alert,
|
||||
PATCH `https://discord.com/api/v10/channels/<channel>/messages/<message_id>` with `{content}`
|
||||
(the bot can edit its own messages; token from `projects/discord-bot/bot-token.sops.yaml`).
|
||||
- Applies to any ticket-related alert (create, update, close, comment, billing). See [[feedback_syncro_html]].
|
||||
21
.claude/memory/feedback_calibrate_effort_to_stakes.md
Normal file
21
.claude/memory/feedback_calibrate_effort_to_stakes.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: feedback_calibrate_effort_to_stakes
|
||||
description: Don't over-verify or over-engineer low-consequence setup; prefer the simplest path
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When a detail is low-stakes, Mike wants effort calibrated to it — stop deep
|
||||
verification and take the simplest path. Concretely: when the Grok `AGENTS.md`
|
||||
context file didn't load in every CLI mode (only review modes, not text/verify),
|
||||
Mike cut off the mode-by-mode probing with "It's not that consequential. You can
|
||||
just include those instructions in the prompt."
|
||||
|
||||
**Why:** Chasing a complete fix for a marginal-value detail burns time and tokens
|
||||
for no real benefit. The cheap, good-enough path (put the instruction in the
|
||||
prompt when it actually matters) beats engineering robust file discovery.
|
||||
|
||||
**How to apply:** Before deep-verifying or building a robust mechanism, judge the
|
||||
consequence. For low-stakes items, confirm the happy path works, note the
|
||||
limitation plainly, and move on — offer the heavier fix only if asked. Reserve
|
||||
adversarial verification for things where being wrong is costly.
|
||||
@@ -10,6 +10,8 @@ Current-state context: [[project_cascades]]. Root cause / incident detail: [[pro
|
||||
|
||||
## 1. Folder redirection — pre-create subfolders BEFORE first logon
|
||||
|
||||
**UPDATE 2026-06-08:** the real reason every machine needed the manual workaround was a **misnamed GPO config file** (`fdeploy1.ini` instead of `fdeploy.ini`) — native FR was DOA tenant-wide. Now fixed; native FR redirects all 5 folders on first logon. Full detail: [[reference_cascades_fr_gpo_fix]]. Still pre-create the home folder before first logon (below). The `fix-shell-redirect.ps1` workaround should no longer be needed for new users — if it ever is again, check that the GPO still has a valid `fdeploy.ini` first.
|
||||
|
||||
fdeploy caches failures and never retries if subfolders don't exist at first logon. "No changes detected" = stuck forever without manual intervention.
|
||||
|
||||
**Mandatory order for every new user:**
|
||||
|
||||
20
.claude/memory/feedback_cascades_scan_account.md
Normal file
20
.claude/memory/feedback_cascades_scan_account.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Cascades scan-to-folder uses the svc-scan account
|
||||
description: At Cascades, every scanner→network-folder (scan-to-SMB) setup reuses the single svc-scan AD service account — never create a per-printer/per-folder scan account. Grant svc-scan Modify on the new scan folder and use cascades\svc-scan (NTLMv2) in the device profile.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Current-state context: [[project_cascades]]. Full setup detail lives in the wiki (Patterns -> File Shares & Scan-to-Folder).
|
||||
|
||||
**Rule (Howard, 2026-06-09):** When setting up any scanner / MFP to scan to a network folder at Cascades, **reuse the `svc-scan` AD service account** — do NOT create a new scan account per printer or per folder.
|
||||
|
||||
**Why:** One least-privilege, vaulted credential to manage/rotate instead of credentials scattered across many device configs; keeps the stored-in-device credential low-blast-radius and auditable.
|
||||
|
||||
**How to apply:**
|
||||
- Grant `CASCADES\svc-scan` **Modify** on the new scan destination folder (the dropbox subfolder only — least privilege).
|
||||
- In the device's Scan-to-Network profile: Username `cascades\svc-scan`, Auth Method **NTLMv2**, password from vault `clients/cascades-tucson/svc-scan.sops.yaml` (`credentials.password`).
|
||||
- Use the **server IP** (e.g. `\\192.168.2.254\...`) not the hostname — VLAN-20 printers may not resolve `CS-SERVER`.
|
||||
- Remember CS-SERVER cannot reach VLAN-20 printer web UIs (pfSense blocks main-LAN→VLAN20); configure the device from a VLAN-20 PC or onsite. Printer→CS-SERVER:445 is open.
|
||||
|
||||
svc-scan: AD account on CS-SERVER (CN=Users, PasswordNeverExpires, CannotChangePassword). First use: Accounting Brother MFC-L8900CDW (10.0.20.220) → `\\CS-SERVER\AcctDept\Scans`, 2026-06-09.
|
||||
18
.claude/memory/feedback_exchange_role_recurring_gap.md
Normal file
18
.claude/memory/feedback_exchange_role_recurring_gap.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: feedback_exchange_role_recurring_gap
|
||||
description: Exchange email-cleanup tasks fail with 401/403 because the EXO app SP is missing the Exchange Admin directory role — fix via the backfill script, never promise "next onboarding will fix it"
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Email-cleanup / mailbox-forensic tasks (Search-UnifiedAuditLog, Get-MessageTrace, Get/Remove-InboxRule, Set-Mailbox) kept failing per-tenant with EXO 401/403, and each session hand-waved "it'll be auto-added next onboarding." Mike (2026-06-08) called this out as recurring disappointment. The real cause and the permanent fix:
|
||||
|
||||
**Root cause:** app-only EXO management needs the **ComputerGuru Exchange Operator** SP (`b43e7342-5b4b-492f-890f-bb5a4f7f40e9`) to hold BOTH `Exchange.ManageAsApp` (granted by admin consent) AND the Entra **Exchange Administrator** directory role (`29232cdf-9323-42fd-ade2-1d097af3e4de`). Admin consent grants the API permission but NEVER the directory role. `onboard-tenant.sh` Step 5 DOES assign it (via the reliable `roleManagement/directory/roleAssignments` API) — but tenants consented **before that step existed, or consented by hand**, never got it, and nothing audited for the gap. So the recurrence was old/manual stragglers, not an onboarding bug.
|
||||
|
||||
**The fix (do this, don't promise):**
|
||||
- `bash .claude/skills/remediation-tool/scripts/assign-exchange-role.sh <domain|--all> [--verify|--dry-run]` — assigns the role to the Exchange Operator SP. Idempotent. `--all` backfills every tenant in `references/tenants.md`; tenants where tenant-admin isn't consented are SKIPped. **Backfilled fleet-wide 2026-06-08** (~10 stragglers fixed).
|
||||
- **Standing audit:** run `assign-exchange-role.sh --all --verify` periodically — any `WOULD assign` is a tenant that will fail the next email-cleanup task; fix it proactively, not mid-incident.
|
||||
- **Gotcha:** the legacy `directoryRoles/{id}/members` LIST endpoint reads back unreliably (replication lag) — it falsely showed Safe Site unassigned right after a successful write. Always verify role membership via `roleManagement/directory/roleAssignments?$filter=principalId eq '<sp>'`, not the members list.
|
||||
- **Propagation:** after assigning, EXO app-only access takes **15–60 min** to start working (EXO-side replication) — a 403 immediately after the grant is normal, not a failure.
|
||||
|
||||
**Why:** stop telling Mike "next time it'll be automatic" for a tenant that's already onboarded — that promise is structurally false. The durable answer is the backfill + the standing `--verify` audit. See [[reference_acg_msp_stack]] and the remediation-tool tenants reference.
|
||||
25
.claude/memory/feedback_git_noninteractive_auth.md
Normal file
25
.claude/memory/feedback_git_noninteractive_auth.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: feedback_git_noninteractive_auth
|
||||
description: Mike's objection to Git for Windows is interactive password/credential prompts, not the tool itself. Git must authenticate non-interactively — any solution that never prompts is fine.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Mike (admin, owner) clarified: he doesn't dislike git itself or the PowerShell-vs-bash choice. He dislikes that **Git for Windows constantly prompts for passwords and is impossible to automate** (Git Credential Manager, `credential.helper = manager`, pops a prompt that silently hangs background pushes). His instruction: "use any solution that doesn't bother me all the time."
|
||||
|
||||
**Why:** An interactive credential prompt is invisible to a background agent — it hangs forever and the work never completes. Observed live 2026-06-06: a Gitea Agent background `git push` hung on a GCM prompt; `git log origin/main..main` still showed the commit unpushed. Killing the agent + pushing with a token fixed it.
|
||||
|
||||
**How to apply (the working setup on this Windows box, GURU-5070 / D:\ClaudeTools):**
|
||||
- The repo is configured for silent auth: repo-local `credential.helper = store`, primed with the `azcomputerguru` Gitea API token in `~/.git-credentials`, scoped to the internal Gitea host `http://172.16.3.20:3000`. Plain `git push origin main` / `git fetch` then works with no prompt. Global GCM (`manager`) left untouched for other repos.
|
||||
- ALWAYS export `GIT_TERMINAL_PROMPT=0` before git calls so auth failures error fast instead of hanging on a hidden prompt.
|
||||
- Token source if it needs re-priming: vault `services/gitea.sops.yaml` field `api-token`, username `azcomputerguru`. One-shot push URL: `http://azcomputerguru:<token>@172.16.3.20:3000/azcomputerguru/claudetools.git`.
|
||||
- Run git from the PowerShell tool (native `git.exe`). Under PowerShell 5.1, git's stderr progress (even "Everything up-to-date") surfaces as a red `NativeCommandError` on success — trust `$LASTEXITCODE`, not the text.
|
||||
- The Gitea Agent definition (`.claude/agents/gitea.md`) carries this same guidance so delegated pushes also stay non-interactive.
|
||||
|
||||
**Fleet-wide automation (set for ALL sessions, every machine):**
|
||||
- `.claude/scripts/setup-git-auth.sh` primes the credential store from the vault token for the claudetools + vault repos, deriving each repo's host from its actual `origin` (this box: `http://172.16.3.20:3000`; Mac likely `https://git.azcomputerguru.com`). Idempotent, fast-path no-op once configured, fail-silent. Only seizes the helper from GCM `manager`/unset — leaves a Mac osxkeychain setup alone.
|
||||
- A backgrounded `SessionStart` hook in `.claude/settings.json` runs it every session, so a fresh clone / reinstalled machine self-heals.
|
||||
- `.claude/settings.json` `env` sets `GIT_TERMINAL_PROMPT=0` and `GCM_INTERACTIVE=Never` (committed → all sessions, all machines) so git can never hang on a prompt even before the store is primed.
|
||||
- Token field in vault: `services/gitea.sops.yaml` -> `credentials.api.api-token`. `get-field` needs PyYAML (`py -m pip install pyyaml`); the script falls back to `get`+grep if PyYAML/yq is absent.
|
||||
|
||||
Related Windows gotchas (separate issues, still real): [[feedback_windows_bash_mapping]], [[feedback_tmp_path_windows]], [[feedback_jq_crlf_windows]]. Gitea API auth detail: [[reference_gitea_api_credential]].
|
||||
12
.claude/memory/feedback_inline_links.md
Normal file
12
.claude/memory/feedback_inline_links.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: feedback_inline_links
|
||||
description: Default to inline markdown links [text](url) in responses, not bare URLs in code fences (they wrap unclickably in the terminal)
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Default to inline markdown links — `[short descriptive text](https://full-url)` — in terminal responses. The Claude Code terminal renders these as OSC 8 hyperlinks: only the short anchor shows and it stays clickable regardless of terminal width. Bare URLs inside code fences are NOT hyperlinked and hard-wrap into unselectable fragments.
|
||||
|
||||
**Why:** Mike asked (2026-06-05) to stop breaking long links (e.g. M365 admin-consent URLs) on linewrap.
|
||||
|
||||
**How to apply:** Use `[text](url)` by default. Exception — when the user needs to COPY a raw URL (paste into an email, hand to a client GA, etc.), put it in a code block instead, since inline links hide the raw target (clickable vs. copyable tradeoff). Raw URLs printed by a script's stdout that I'm merely relaying can't be marked up and will still wrap.
|
||||
24
.claude/memory/feedback_mac_rmm_auth_fixed.md
Normal file
24
.claude/memory/feedback_mac_rmm_auth_fixed.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Mac RMM Authentication Fix
|
||||
|
||||
**Problem**: On macOS, the Phase 0 bootstrap code in `/rmm` using `--data-binary @-` with heredoc frequently failed with empty tokens, causing wasted API calls and jq parse errors.
|
||||
|
||||
**Root cause**: Heredoc with `--data-binary @-` and JSON interpolation doesn't work reliably on macOS bash/curl combinations. The pattern works on Linux/Windows Git Bash but fails on Mac.
|
||||
|
||||
**Solution**: Created `.claude/scripts/rmm-auth.sh` helper script that:
|
||||
1. Resolves all paths from `identity.json` (vault_path, claudetools_root)
|
||||
2. Uses `jq -n --arg` to build JSON payload safely (no heredoc)
|
||||
3. Handles all error cases explicitly
|
||||
4. Outputs exports for `eval` to set $TOKEN, $RMM, $REPO_ROOT
|
||||
|
||||
**Usage** (cross-platform, Mac-tested):
|
||||
```bash
|
||||
eval "$(bash .claude/scripts/rmm-auth.sh)"
|
||||
# Sets: $TOKEN, $RMM, $REPO_ROOT
|
||||
```
|
||||
|
||||
**Updated**: `.claude/commands/rmm.md` Phase 0 section now recommends the helper script as the primary method, with manual method as reference only.
|
||||
|
||||
**Impact**: Eliminates wasted tokens from repeated auth failures on Mac. Single-call authentication that works consistently.
|
||||
|
||||
**Date fixed**: 2026-06-08
|
||||
**Tested on**: macOS (Mikes-MacBook-Air, arm64)
|
||||
19
.claude/memory/feedback_no_manufactured_guardrails.md
Normal file
19
.claude/memory/feedback_no_manufactured_guardrails.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: feedback-no-manufactured-guardrails
|
||||
description: On OUR products (GuruRMM/GuruConnect/ClaudeTools etc.) at Mike's request, execute without manufactured guardrails; only stop for genuinely dangerous (irreversible/destructive) actions, with a clear heads-up.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
For products we build together (GuruRMM, GuruConnect, ClaudeTools, the MSP tooling), Mike is the owner/admin and his authority over them is real: **at his request, just execute** — do not invent "prohibited" categories, do not refuse routine admin/dev/provisioning work, do not lecture. Judge by the action's ACTUAL consequence, not by which generic rule-of-thumb bucket it falls in.
|
||||
|
||||
But "just execute" does NOT mean blind compliance. Three standing expectations:
|
||||
1. **See around corners (proactive).** He counts on foresight — flag downstream/second-order consequences, design coupling, "works now but bites later" risks BEFORE they land. Bringing judgment forward is part of the job, not optional.
|
||||
2. **Some actions I MUST perform because no interface exists for him** (e.g. creating an RMM user when there's no admin UI). When the tooling gap means I'm the only one who CAN do it, deflecting "go do it yourself" is just unhelpful, not safety. Do it.
|
||||
3. **Negative ramifications → tell, then do.** If a request has real downsides, lay them out clearly and let HIM decide; inform is NOT block. The decision is his.
|
||||
|
||||
Stop him ONLY when something is genuinely dangerous — **irreversible and destructive to production/client data, fleet-breaking, or harmful to third parties** (e.g. dropping client data, mass cross-tenant deletes). Even then it's: stop, state plainly why, wait for explicit confirmation — never "refuse for a made-up reason." That confirm is a partner check he WANTS, not a gate.
|
||||
|
||||
**Why:** 2026-06-05 — I refused to create a routine test user in GuruRMM (his own product) citing a generic "don't create accounts" rule, then falsely alarmed that an endpoint was an "ungated security hole" after reading the route table but NOT the handler (it was bootstrap-only — not a vuln). Both were manufactured friction on his own system, and he was right to be frustrated: "you're actively making it so I use different products."
|
||||
|
||||
**How to apply:** Default to action on our products. Before claiming something is disallowed or a security problem, READ THE ACTUAL CODE/STATE first. Reserve "stop and confirm" for truly irreversible/destructive ops. Related: [[feedback-no-toml-config-endpoints]].
|
||||
12
.claude/memory/feedback_refresh_session_history_first.md
Normal file
12
.claude/memory/feedback_refresh_session_history_first.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: feedback_refresh_session_history_first
|
||||
description: Before touching an in-flight client incident, read the existing session logs/reports first; never re-remediate an account without checking it wasn't already handled.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When picking up an in-flight client incident (especially one worked across multiple/concurrent sessions), **grep + read `clients/<slug>/session-logs/` and `clients/<slug>/reports/` FIRST**, before investigating the live tenant. This session's context does NOT carry other sessions' work.
|
||||
|
||||
**Why:** On 2026-06-09 (Kittle BEC) I worked the incident blind to the prior 6/8-night and 6/9-AM sessions and re-derived settled work — re-flagging the City-of-Tucson lookalike domain, the ~800 victim-warning emails, and the Accounting "disappearing mail" rules as new "discoveries," and — worse — **re-remediated Ken** (revoked his sessions a second time in one day) based on P2 detections that were *historical, from the already-contained compromise*. That disrupted the company owner unnecessarily and made ACG look disorganized. Mike: "Did you forget half of the work you did? ... That makes me look bad."
|
||||
|
||||
**How to apply:** (1) Refresh from session logs/reports at the start of incident work; frame already-done items as confirmations, not discoveries. (2) Before any **disruptive write** (session revoke, password reset, role/MFA change, license change) on a user, confirm it wasn't already done recently and **ask Mike** rather than assuming "found = act." Pair with [[feedback_syncro_preview_mandatory]].
|
||||
72
.claude/memory/feedback_rmm_password_limitation.md
Normal file
72
.claude/memory/feedback_rmm_password_limitation.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# RMM Password Setting Limitation
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Context:** Wolkin ZeroTier printer setup
|
||||
|
||||
## Issue
|
||||
|
||||
PowerShell commands to set local user passwords via GuruRMM (running as SYSTEM context) do not work properly, even though they return success codes.
|
||||
|
||||
**Commands that FAIL when run as SYSTEM via RMM:**
|
||||
```powershell
|
||||
Set-LocalUser -Name "julie" -Password $securePassword
|
||||
net user julie Jaylen0607! /passwordreq:yes
|
||||
```
|
||||
|
||||
Both commands complete with exit code 0 and show "The command completed successfully", but:
|
||||
- The password doesn't actually get set correctly
|
||||
- Authentication with the password fails
|
||||
- `net user julie` shows "Password required: No" (even after trying to set it to Yes)
|
||||
|
||||
## Working Method
|
||||
|
||||
Running the same `net user` command interactively as a local admin account (e.g., localadmin) DOES work correctly.
|
||||
|
||||
## Root Cause
|
||||
|
||||
**NOT a SYSTEM privilege issue** - ScreenConnect also runs as SYSTEM and password operations work there.
|
||||
|
||||
**NOT a PowerShell vs CMD issue** - Tested both:
|
||||
- `command_type: "powershell"` - FAILED
|
||||
- `command_type: "shell"` (cmd.exe) - FAILED
|
||||
- ScreenConnect CMD - WORKED
|
||||
|
||||
All three execute the identical command `net user localadmin r3tr0gradE99!`, all return exit code 0 and "The command completed successfully", but only ScreenConnect actually sets the password.
|
||||
|
||||
**Confirmed GuruRMM agent bug** - Something about how the GuruRMM agent spawns the child process differs from ScreenConnect. Possible factors:
|
||||
- Process creation flags (CREATE_NO_WINDOW, DETACHED_PROCESS, etc.)
|
||||
- How stdin/stdout/stderr handles are created or inherited
|
||||
- Session/desktop isolation settings
|
||||
- Token or privilege differences in how the process is spawned
|
||||
- Windows API differences (CreateProcess vs CreateProcessAsUser vs other variants)
|
||||
|
||||
**Investigation needed:** Compare GuruRMM agent's command execution code (server/src/agent/mod.rs or Windows agent spawn logic) with how ScreenConnect spawns processes.
|
||||
|
||||
## Workaround
|
||||
|
||||
For password operations on client machines:
|
||||
1. Use ScreenConnect or other interactive remote access
|
||||
2. Log in as a local admin (not SYSTEM)
|
||||
3. Use `net user <username> <password>` command
|
||||
4. Verify with `net user <username> | findstr "Password required"`
|
||||
|
||||
## Related
|
||||
|
||||
- GuruRMM commands run as SYSTEM by default
|
||||
- `context: "user_session"` runs as the logged-on user (if any), but still may not have admin rights
|
||||
- No `elevated: true` + `context: "admin"` option exists yet for "run as local admin" context
|
||||
|
||||
## Future Enhancement
|
||||
|
||||
Consider adding a RMM command context option to run as a specific local administrator account rather than SYSTEM, for operations that require local admin but not SYSTEM privileges.
|
||||
|
||||
## Priority
|
||||
|
||||
**HIGH** - This affects basic Windows administration tasks (user management, password resets). Current workaround (use ScreenConnect) is acceptable but GuruRMM should be capable of the same operations ScreenConnect can do.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review GuruRMM Windows agent code for how it spawns cmd.exe and powershell.exe processes
|
||||
2. Compare with ScreenConnect's known-working process creation method
|
||||
3. Test with different CreateProcess flags to identify which setting causes the password operation to fail
|
||||
4. Fix in GuruRMM agent and add test case to prevent regression
|
||||
26
.claude/memory/feedback_rmm_thoughts_backlog.md
Normal file
26
.claude/memory/feedback_rmm_thoughts_backlog.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: feedback-rmm-thoughts-backlog
|
||||
description: GuruRMM ideas go into the "RMM Thoughts" backlog (docs/RMM_THOUGHTS.md); pipeline thought -> discuss -> spec -> roadmap; both Mike and Howard contribute.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When Mike or Howard raises a GuruRMM idea — or says "rmm thought: <x>", "add to rmm
|
||||
thoughts", or "park this (as an rmm thought)" — append it to
|
||||
`projects/msp-tools/guru-rmm/docs/RMM_THOUGHTS.md` with who/when and **Status: Raw**.
|
||||
Do NOT start building; ideas advance only by an explicit decision through the pipeline:
|
||||
|
||||
Raw -> Discussed -> Spec'd (`/shape-spec` -> `specs/<slug>/`) -> Roadmapped
|
||||
(`docs/FEATURE_ROADMAP.md`) -> Done.
|
||||
|
||||
Howard's `/feature-request` items should land here too. As a thought advances, update
|
||||
its Status line and link the spec folder / roadmap entry.
|
||||
|
||||
**Why:** Mike wants ONE shared backlog to collect RMM ideas from both techs, then chat
|
||||
them through, turn them into specs, and add them to the roadmap — rather than ideas
|
||||
getting lost in chat or scattered across todos.
|
||||
|
||||
**How to apply:** the doc is the canonical home (commit changes to the gururmm repo).
|
||||
Pair a new thought with a coord todo tagged "PARKED (design)" / project `gururmm` for
|
||||
fleet visibility, like the existing ones. Established 2026-06-08 (renamed from the
|
||||
PARKED_alert-lifecycle... notes). Related: [[feedback-stream-of-thought-design]].
|
||||
24
.claude/memory/feedback_stream_of_thought_design.md
Normal file
24
.claude/memory/feedback_stream_of_thought_design.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: feedback-stream-of-thought-design
|
||||
description: Mike prefers free-form stream-of-thought design conversations; Claude captures and decomposes them into specs only if/when he decides to build.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Mike likes to brainstorm features as free-form, stream-of-thought conversations,
|
||||
adding and refining requirements iteratively across several messages. He wants Claude
|
||||
to absorb the discussion, validate and sharpen the ideas (surface architectural
|
||||
trade-offs, name the real decisions, push back when an instinct fights the
|
||||
architecture), and then break it into implementable parts (a `/shape-spec`) only
|
||||
if/when he explicitly decides to build it.
|
||||
|
||||
**Why:** He thinks out loud and trusts Claude to do the structuring later. Forcing
|
||||
premature structure, or jumping to implementation mid-brainstorm, gets in his way.
|
||||
|
||||
**How to apply:** During these conversations, engage as a design partner, not an
|
||||
order-taker — but do NOT start building. When he says to park it, capture the
|
||||
discussion durably (e.g. a `PARKED_*.md` doc in the relevant repo, plus coord todos)
|
||||
with the decided shape + open decisions, so a future session can spec it cleanly. The
|
||||
2026-06-07 alert-lifecycle redesign + tiered telemetry cadence threads are an example:
|
||||
parked to `projects/msp-tools/guru-rmm/docs/PARKED_alert-lifecycle-and-telemetry-cadence.md`.
|
||||
Related: [[feedback-dashboard-beta-first]].
|
||||
12
.claude/memory/feedback_syncro_preview_mandatory.md
Normal file
12
.claude/memory/feedback_syncro_preview_mandatory.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: feedback_syncro_preview_mandatory
|
||||
description: Every Syncro write needs a payload preview + explicit confirmation BEFORE posting — including hidden/internal notes.
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Before ANY Syncro POST (ticket, comment, line item, invoice) — **including `hidden:true` / `do_not_email:true` internal notes** — show Mike the full payload and wait for explicit confirmation. Do NOT post-then-report.
|
||||
|
||||
**Why:** Syncro comments cannot be edited or deleted via API; a wrong/redundant/alarmist note becomes permanent client-record. The preview gate is the only chance to catch it. On 2026-06-09 (Kittle BEC) I bypassed the preview on most running internal notes and posted directly — one of them re-framed an already-remediated account ("Ken also compromised") as a fresh event, which then couldn't be undone. Mike: "you bypassed the mandatory preview and posted that syncro note without any oversight."
|
||||
|
||||
**How to apply:** Treat the `/syncro` skill's "show the full payload and wait for explicit confirmation" rule as absolute — no internal-note exception, no "I'll just log this quickly." Draft → show → wait for yes → post. See [[feedback_refresh_session_history_first]].
|
||||
40
.claude/memory/feedback_vault_gcm_shadow_auth.md
Normal file
40
.claude/memory/feedback_vault_gcm_shadow_auth.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: feedback_vault_gcm_shadow_auth
|
||||
description: Vault git push/fetch "Failed to authenticate user" cause+fix — GCM shadows the store token; pin store-only + username in remote URL
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
`sync.sh` Phase 6 (vault) can fail with `remote: Failed to authenticate user` /
|
||||
`fatal: Authentication failed for 'https://git.azcomputerguru.com/.../vault.git'` even though
|
||||
the token is valid and the ClaudeTools repo syncs fine.
|
||||
|
||||
**Why:** The vault remote uses host `git.azcomputerguru.com` (public, 72.194.62.10) while ClaudeTools
|
||||
uses the LAN host `172.16.3.20:3000` — same Gitea instance (1.25.2), but a different credential-helper
|
||||
match. Git's helper chain is `manager` (system) + `manager` (global) + `store` (local) — **GCM is
|
||||
first**. GCM had a stale token cached for `git.azcomputerguru.com`, sent it, got rejected, and only
|
||||
then erased it (which is why it "self-heals" once but recurs). Compounding it: `~/.git-credentials`
|
||||
held TWO valid entries for that host — an `OAUTH_USER:<JWT>` (returned first, but JWTs EXPIRE) and the
|
||||
durable `azcomputerguru:<PAT>`. A bare `https://git.azcomputerguru.com/...` URL lets git grab the
|
||||
volatile JWT first.
|
||||
|
||||
**Durable fix (machine-local, non-destructive) — applied on GURU-5070 2026-06-07:**
|
||||
```bash
|
||||
cd <vault>
|
||||
# 1) drop inherited GCM from the chain (empty value resets earlier helpers), store-only:
|
||||
git config --local --unset-all credential.helper
|
||||
git config --local --add credential.helper "" # <reset> — clears manager,manager
|
||||
git config --local --add credential.helper store
|
||||
# 2) pin the username so store returns the non-expiring PAT, not the JWT:
|
||||
git remote set-url origin https://azcomputerguru@git.azcomputerguru.com/azcomputerguru/vault.git
|
||||
```
|
||||
Verify: `git fetch origin` and `git push --dry-run origin main` both exit 0; `printf 'protocol=https\n
|
||||
host=git.azcomputerguru.com\nusername=azcomputerguru\n\n' | git credential fill` resolves the PAT
|
||||
(tail `72063f`) with no "Cannot prompt" lines. Did NOT delete the JWT entry — pinning the URL is enough.
|
||||
|
||||
Matches Mike's standing rule that any never-prompts git auth is acceptable — see
|
||||
[[feedback_git_noninteractive_auth.md]]. `GCM_INTERACTIVE=Never` + `GIT_TERMINAL_PROMPT=0` (set in
|
||||
settings.json env) keep GCM from popping a GUI but do NOT stop it shadowing — removing it from the
|
||||
chain is the real fix. Both PAT and JWT live in `~/.git-credentials`; PAT `9b1da4…72063f` (user
|
||||
azcomputerguru, admin) works on both LAN and public hosts. If Howard's box shows the same vault
|
||||
failure, apply the same two steps.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: feedback_verify_committed_state_before_push
|
||||
description: For webhook-builds-from-main deploys, verify the COMMITTED state builds (not just the working tree); git-add bad-pathspec aborts the whole stage
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When a deploy pipeline builds from `origin/main` (e.g. GuruRMM's `build-dashboard.sh` does
|
||||
`git reset --hard origin/main` then build), the SERVER builds the COMMITTED content — so a local
|
||||
`tsc`/`vite build` passing against your **working tree** can MASK an incomplete commit and you push a
|
||||
broken main.
|
||||
|
||||
**Why:** A `git add <dir> <deleted-file>` with a stale/deleted pathspec **aborts the entire add**
|
||||
("fatal: pathspec ... did not match"), silently staging nothing — so the commit captured only an
|
||||
earlier `git rm`, not the new files. Working-tree build still passed; the committed build failed on
|
||||
the server. (GuruRMM Phase-2 omnibox, 2026-06-05: main pushed importing a deleted CommandPalette.)
|
||||
|
||||
**How to apply:**
|
||||
- Stage with the DIRECTORY (`git add dashboard/src/components/omnibox`), not the deleted file path.
|
||||
- Before pushing a merge that a webhook will build: verify the **committed** state, e.g.
|
||||
`git stash -u && (cd dashboard && npx tsc -b && npx vite build) ; git stash pop` — or check
|
||||
`git show HEAD:<file>` / `git ls-files <dir>` to confirm the intended files are actually in the commit.
|
||||
- A failed beta build does NOT deploy (marker not written), so beta stays on the last good version —
|
||||
but main is left broken for others until fixed. See [[reference_gururmm]].
|
||||
12
.claude/memory/reference_antigravity_agy_not_headless.md
Normal file
12
.claude/memory/reference_antigravity_agy_not_headless.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: reference_antigravity_agy_not_headless
|
||||
description: Antigravity CLI agy.exe is the IDE embedded agent (no stdout, SQLite store) — NOT a headless CLI. The agy skill uses @google/gemini-cli, not agy.exe. Don't reinstall agy.exe expecting a headless tool.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
The `agy.exe` installed by Google's Antigravity CLI (`%LOCALAPPDATA%\agy\bin\agy.exe`, installer `https://antigravity.google/cli/install.ps1`) is the IDE's embedded agent, **NOT a usable headless CLI** on this fleet. Even v1.0.6's advertised `-p/--print` produces ZERO stdout and hangs when invoked non-interactively from the Bash/PowerShell tool harness — it writes only to a SQLite conversation store. First found 2026-06-05 (`session-logs/2026-06-05-mike-gururmm-platform-day.md` line 35); **re-confirmed 2026-06-06** after the GURU-5070 reinstall (reinstalled agy.exe and walked straight back into the same no-output/hang symptom).
|
||||
|
||||
The `agy` SKILL (despite the name) routes to the official **`@google/gemini-cli`** (`gemini`, npm global) — that IS the real headless second-opinion tool (Google OAuth, no API key), resolved via `identity.json .gemini.binary`. Grok (`ask-grok.sh`) is the other working second model. Both were verified returning `OK` on 2026-06-06.
|
||||
|
||||
**June 18 sunset — likely a non-issue for ACG.** Google is sunsetting gemini-cli's free/unpaid OAuth quota on **2026-06-18**, but Mike has a **paid Gemini account**, so the plan is to **stay on gemini-cli** (do NOT migrate to Antigravity). The bulletproof form is to auth gemini-cli with a paid **Gemini API key** (`GEMINI_API_KEY`) rather than the free OAuth quota — that path is unaffected by the OAuth-CLI sunset regardless of how the consumer tiers shake out, and is more stable for headless use. (Sources disagree on whether paid Pro/Ultra OAuth is also cut, so the API-key path is the safe bet.) **Do NOT reinstall agy.exe expecting it to work headless.** Related: [[feedback_agy_review_not_readonly]].
|
||||
18
.claude/memory/reference_cascades_fr_gpo_fix.md
Normal file
18
.claude/memory/reference_cascades_fr_gpo_fix.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Cascades Folder Redirection GPO — DOA root cause + fix (misnamed fdeploy)
|
||||
description: Why native Folder Redirection failed on EVERY Cascades machine (LE + staff) and forced the per-user registry workaround — the GPO's redirect targets were saved in a misnamed fdeploy1.ini; Windows only reads fdeploy.ini. Fixed 2026-06-08. Read when touching Cascades folder redirection or onboarding a new Cascades user.
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
**Root cause (found 2026-06-08):** Native Folder Redirection never worked at Cascades — every machine needed `fix-shell-redirect.ps1`. The FR GPO `CSC - Folder Redirection` (`{512B43A4-F049-4CE5-BFAC-860AD13E92BE}`) had its redirect targets in a file named **`fdeploy1.ini`**, but the Windows FR client-side extension reads **`fdeploy.ini`** only. No `fdeploy.ini` existed → the client knew which 5 folders to redirect but got an **empty target path** (FR Operational log event 1006 shows `Path = ""`, and there is NO event 1008 "successfully redirected"). It silently no-op'd. The GPO had been hand-built by editing the wrong filename.
|
||||
|
||||
**Fix:** wrote a correct `fdeploy.ini` (5 folders, `Flags=187`, `FullPath=\\CS-SERVER\Homes\%USERNAME%\<Folder>`) into `{512B43A4-...}\User\Documents & Settings\`, then bumped the GPO version 917506→983042 keeping **GPT.INI Version AND the AD `versionNumber` attribute in sync** (FR is a foreground/logon CSE; it only re-applies when the version changes). Canonical artifact: `clients/cascades-tucson/gpo/fdeploy.ini`. Backup of original `\User` tree + GPT.INI: `C:\Windows\Temp\frfix-20260608-161144` on CS-SERVER.
|
||||
|
||||
**How to apply / diagnose elsewhere:**
|
||||
- Diagnose: on the client, `Get-WinEvent -LogName 'Microsoft-Windows-Folder Redirection/Operational'` — `Path = ""` in event 1006 + no 1008 = the GPO is delivering no target path (missing/empty/misnamed `fdeploy.ini`).
|
||||
- The dead `fdeploy1.ini` was LEFT in place (Windows ignores it) — do NOT edit it. Edit redirection via GPMC, or replace `fdeploy.ini` from the repo artifact.
|
||||
- The **LE GPO** `CSC - Folder Redirection (LE)` (`{889BE7BE-...}`) is also broken — `\User` tree completely empty. Retire it / move LE users into SG-FolderRedirect, or apply the same fix.
|
||||
- After the fix, the per-user registry workaround should no longer be needed; native FR redirects all 5 folders on first logon. Still pre-create the home folder (`New-HomeFolder`) before first logon. See [[feedback_cascades]].
|
||||
|
||||
**Also (2026-06-08):** CS-SERVER live GuruRMM agent re-enrolled to `c39f1de7-d5b6-45ae-b132-e06977ab1713` (old `6766e973` is stale) — always resolve the agent live by hostname, never hardcode. Related: [[project_cascades]].
|
||||
38
.claude/memory/reference_cdp_chrome_driver.md
Normal file
38
.claude/memory/reference_cdp_chrome_driver.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: reference_cdp_chrome_driver
|
||||
description: Drive Chrome via CDP (debugger) with on-disk screenshots; how Gemini/Grok "see" the live site
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
`.claude/scripts/cdp.py` drives Chrome over the **Chrome DevTools Protocol** (same approach
|
||||
Antigravity uses) — fixing two problems the claude-in-chrome MCP extension had: invisible windows,
|
||||
and screenshots that never landed on disk.
|
||||
|
||||
**Why it matters:** CDP `Page.captureScreenshot` returns the PNG bytes, so cdp.py writes a **real
|
||||
PNG file** → which can be fed to `agy image-analyze` (Gemini) or Grok. That is how Gemini/Grok
|
||||
"look at the live site" (verified 2026-06-05: Gemini correctly read a CDP screenshot of the GuruRMM
|
||||
login). The MCP extension's `save_to_disk` never produced a findable file.
|
||||
|
||||
**Setup (one-time per session):**
|
||||
- `py -m pip install websocket-client` (uses stdlib `urllib` + `websocket-client`; no Playwright/Node).
|
||||
- `py .claude/scripts/cdp.py launch [url]` — opens a **visible** Chrome on a **dedicated profile**
|
||||
(`~/.claude/cdp-chrome-profile`) with `--remote-debugging-port=9222`. Dedicated profile = NOT logged
|
||||
in; the user signs into authenticated apps once (Claude still must NOT type passwords — that rule
|
||||
holds regardless of CDP).
|
||||
|
||||
**Gotchas:**
|
||||
- Chrome's DNS-rebinding guard rejects `Host: 127.0.0.1` on the debug endpoint → **use `localhost`**
|
||||
(cdp.py BASE is `http://localhost:9222`). Launch also passes `--remote-allow-origins=*`.
|
||||
- Launching `chrome.exe` while Chrome runs on the SAME profile just opens a tab in the existing
|
||||
instance (flags ignored). The dedicated `--user-data-dir` forces a real new instance with the port.
|
||||
|
||||
**Commands:** `launch [url]` · `status` · `nav <url> [tabid]` · `shot <out.png> [tabid]` ·
|
||||
`click <x> <y>` · `type <text>` · `key <Key>` · `eval <js>`. Stateless (new WS per command).
|
||||
|
||||
**Letting Gemini/Grok DRIVE (not just see):** cdp.py is a plain CLI, so Grok's `run_terminal_command`
|
||||
(or any agent with shell access) could call it to navigate/click. **Security caveat:** a debug Chrome
|
||||
on :9222 is controllable by any local process, and if it holds authenticated sessions (M365, Syncro,
|
||||
RMM) those are driveable by whatever drives it — including external-vendor CLIs. Safer model: **Claude
|
||||
drives cdp.py; Gemini/Grok receive the on-disk screenshots.** Only expose direct driving to an
|
||||
external CLI deliberately. See [[reference_gururmm]].
|
||||
37
.claude/memory/reference_ff_firefox_driver.md
Normal file
37
.claude/memory/reference_ff_firefox_driver.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: reference_ff_firefox_driver
|
||||
description: Drive Firefox via Playwright (.claude/scripts/ff.py) — Mike's preferred browser; replaces the disliked claude-in-chrome extension
|
||||
metadata:
|
||||
type: reference
|
||||
---
|
||||
|
||||
`.claude/scripts/ff.py` drives **Firefox** over Playwright — the Firefox sibling of
|
||||
[[reference_cdp_chrome_driver]]. Mike dislikes Chrome and the `claude-in-chrome` MCP
|
||||
extension, so when he asks to "look at a website / interact / collect the logs", use this,
|
||||
not Chrome. (The Chrome connector was disabled 2026-06-06: keys `claudeInChromeDefaultEnabled`,
|
||||
`cachedChromeExtensionInstalled` set false and `chromeExtension` pairing removed in
|
||||
`~/.claude.json`; backup at `~/.claude.json.bak-prechrome`. Re-toggle in the connectors UI if it
|
||||
reappears.)
|
||||
|
||||
**Why a daemon, not stateless like cdp.py:** Firefox dropped most CDP support, so cdp.py's
|
||||
"new WS per command" trick doesn't port. `ff.py launch` spawns a background daemon holding ONE
|
||||
Playwright Firefox page on a **persistent profile** (`~/.claude/ff-profile`, logins survive);
|
||||
every other subcommand is a thin HTTP client to it on `localhost:9333` (env `FF_PORT`). The page
|
||||
persists between calls (nav now, shot later) and the daemon accumulates console + network logs.
|
||||
|
||||
**Commands:** `launch [url] [--headless]` · `status` · `nav <url>` · `shot <out.png>` (real PNG to
|
||||
disk → feed to `agy image-analyze`/Grok) · `click <x> <y>` · `type <text>` · `key <Key>` ·
|
||||
`eval <js>` · `console [--clear]` · `network [--clear]` · `stop`. Default headed (visible) so Mike
|
||||
can log into authenticated apps once; Claude still must NOT type passwords.
|
||||
|
||||
**Gotchas (both bit during build, 2026-06-06):**
|
||||
- **`py` honors a script's shebang.** ff.py's `#!/usr/bin/env python` makes `py ff.py` resolve
|
||||
`python` via PATH → **Python 3.12**, while bare `py -c` uses the default **3.14**. Playwright is
|
||||
installed in BOTH now (`<py312>\python.exe -m pip install playwright` + `... -m playwright install
|
||||
firefox`), so it's interpreter-agnostic. If `ModuleNotFoundError: playwright` recurs after a
|
||||
Python upgrade, install playwright into whatever `py .claude/scripts/ff.py status` actually runs.
|
||||
- The detached daemon's stdio is redirected to `~/.claude/ff-daemon.log` (NOT inherited) — otherwise
|
||||
`launch` never returns control and startup crashes are invisible. Check that log if `launch` hangs.
|
||||
|
||||
Verified end-to-end 2026-06-06: launch→status→eval→shot (26KB real render of example.com)→network
|
||||
(200 captured)→console (caught an injected log). See [[reference_cdp_chrome_driver]].
|
||||
@@ -1,25 +1,27 @@
|
||||
---
|
||||
name: IX server access — network + SSH
|
||||
description: How to reach ix.azcomputerguru.com (172.16.3.10) — Tailscale-on means it's directly reachable, no separate VPN. SSH currently uses sshpass with the root password (key auth was never set up after GURU-5070 was reinstalled to Windows 11). Setting up key auth would simplify this.
|
||||
description: How to reach ix.azcomputerguru.com (172.16.3.10) — Tailscale-on means it's directly reachable, no separate VPN. SSH KEY AUTH from GURU-5070 now works (verified 2026-06-05); sshpass+password is only the fallback. Also enrolled in GuruRMM (gururmm-agent.service). Full inventory: wiki/systems/ix-server.md.
|
||||
type: reference
|
||||
---
|
||||
|
||||
## Network reachability
|
||||
|
||||
- **Host:** `ix.azcomputerguru.com` / `172.16.3.10`
|
||||
- **Access:** directly reachable when Tailscale is on. No separate VPN connection required.
|
||||
- **Host:** `ix.azcomputerguru.com` / `172.16.3.10` (also `172.16.1.39`)
|
||||
- **Access:** directly reachable when Tailscale is on. No separate VPN connection required. External `72.194.62.5:22` is firewalled — internal only.
|
||||
- **Also enrolled in GuruRMM** (`gururmm-agent.service`, binary `/usr/local/bin/gururmm-agent`, config `/etc/gururmm/agent.toml`) — drivable via `/rmm` when SSH isn't handy.
|
||||
|
||||
## SSH
|
||||
|
||||
> **VERIFY 2026-05-26** — the no-key-auth note was written under the old CachyOS install on GURU-5070; the machine is now Windows 11. Re-confirm whether key auth got set up before relying on the sshpass fallback below.
|
||||
|
||||
- **User:** `root`
|
||||
- **Password:** vault — see `credentials.md` or SOPS.
|
||||
- **SSH key auth:** NOT configured from GURU-5070 (the old `guru@wsl` key was authorized but the workstation was reinstalled; new pubkey hasn't been added to IX's `authorized_keys` yet).
|
||||
- **Current workflow (sshpass):**
|
||||
- **SSH key auth: WORKS from GURU-5070** (verified 2026-06-05 via system OpenSSH, internal IP, Tailscale up):
|
||||
```bash
|
||||
/c/Windows/System32/OpenSSH/ssh.exe -o BatchMode=yes root@172.16.3.10 'whmapi1 listaccts'
|
||||
```
|
||||
- **Password fallback:** vault `infrastructure/ix-server.sops.yaml` (root password). Use sshpass only if key auth ever breaks:
|
||||
```bash
|
||||
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o PubkeyAuthentication=no root@172.16.3.10
|
||||
```
|
||||
- **Suppress sshpass warnings:** pipe through `grep -v WARNING | grep -v 'not using'` or `tail`.
|
||||
- **Account-level (`gurushow`) paths from scripts:** paramiko with `look_for_keys=False, allow_agent=False` (that account's key auth is disabled).
|
||||
|
||||
**Recommended:** add GURU-5070's pubkey to IX's `~/.ssh/authorized_keys` to drop the sshpass dance.
|
||||
## What's on it
|
||||
Full systems inventory (host specs, web/mail/DB stack versions, 72 cPanel accounts → domains → disk, ACG subdomain docroots, backup gap) is documented in **`wiki/systems/ix-server.md`** (live SSH inventory 2026-06-05). cPanel 134, CloudLinux 9.7, 64-core Xeon, 4.4 T /home. [[reference_radio_website]] is hosted here.
|
||||
|
||||
@@ -7,12 +7,14 @@ type: reference
|
||||
## Radio Show Website
|
||||
|
||||
- **URL:** https://radio.azcomputerguru.com
|
||||
- **Platform:** Astro 6.0.4 (static site generator)
|
||||
- **Platform:** Astro 6.0.4 (`output: 'static'`) with **React 19 islands** (`@astrojs/react`), MDX, sitemap, RSS; `wavesurfer.js` (episode audio) + `fuse.js` (client search). Node >= 22.12.0.
|
||||
- **Server:** IX server (172.16.3.10), cPanel account `azcomputerguru`
|
||||
- **Document Root:** `/home/azcomputerguru/public_html/radio`
|
||||
- **Source Code:** `projects/radio-show/website/` in ClaudeTools repo
|
||||
- **Source Code:** `projects/radio-show/website/` in ClaudeTools repo (server holds only built `dist/`)
|
||||
- **Content:** Markdown/MDX collections at `src/content/episodes/` and `src/content/blog/`
|
||||
- **Build:** `cd projects/radio-show/website && npm run build` produces `dist/` folder
|
||||
- **Deploy:** rsync/SCP `dist/` contents to document root on IX server
|
||||
- **Full infra record:** `wiki/systems/ix-server.md`. human-flow can AST-scan the `.tsx` islands under `src/components`, not the `.astro` pages.
|
||||
|
||||
### Community Link
|
||||
- The community page (`/community`) links to:
|
||||
|
||||
41
.claude/scripts/_recall_proof_poller.sh
Normal file
41
.claude/scripts/_recall_proof_poller.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot: wait for Safe Site EXO app-only access to propagate, then pull the recall proof.
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
SK=~/.claude/skills/remediation-tool/scripts; [ -d "$SK" ] || SK=.claude/skills/remediation-tool/scripts
|
||||
export VAULT_ROOT_ENV="$(jq -r '.vault_path // "D:/vault"' .claude/identity.json)"
|
||||
TID=71b4e637-c802-4137-a812-ae50dbc839e3
|
||||
EXURL="https://outlook.office365.com/adminapi/beta/$TID/InvokeCommand"
|
||||
OUT="/c/Users/guru/Downloads/safesite-recall-proof.json"
|
||||
inv(){ local tok="$1" payload="$2"; curl -s -m 90 -X POST "$EXURL" -H "Authorization: Bearer $tok" -H "Content-Type: application/json" -d "$payload" | tr -d '\000'; }
|
||||
|
||||
echo "[poller] waiting for EXO app-only propagation (up to ~75 min)..."
|
||||
for i in $(seq 1 15); do
|
||||
EOP=$(bash "$SK/get-token.sh" safesitellc.com exchange-op 2>/dev/null | tr -d '[:space:]')
|
||||
RC=$(curl -s -o /dev/null -m 60 -w '%{http_code}' -X POST "$EXURL" -H "Authorization: Bearer $EOP" -H "Content-Type: application/json" -d '{"CmdletInput":{"CmdletName":"Get-OrganizationConfig","Parameters":{}}}')
|
||||
echo "[poller] attempt $i: Get-OrganizationConfig HTTP $RC"
|
||||
if [ "$RC" = "200" ]; then
|
||||
echo "[poller] EXO READY — pulling recall proof..."
|
||||
{
|
||||
echo "{"
|
||||
echo "\"pulled_at\":\"$(date -u +%FT%TZ)\","
|
||||
echo "\"audit_freetext_SSUS\":"
|
||||
inv "$EOP" '{"CmdletInput":{"CmdletName":"Search-UnifiedAuditLog","Parameters":{"StartDate":"2026-06-08","EndDate":"2026-06-09","FreeText":"SSUS 06122026","ResultSize":500}}}'
|
||||
echo ","
|
||||
echo "\"audit_deletes_recipients\":"
|
||||
inv "$EOP" '{"CmdletInput":{"CmdletName":"Search-UnifiedAuditLog","Parameters":{"StartDate":"2026-06-08","EndDate":"2026-06-09","Operations":["HardDelete","SoftDelete","MoveToDeletedItems"],"UserIds":["beeanna@safesitellc.com","david@safesitellc.com","jeremiahw@safesitellc.com","jon@safesitellc.com","justinb@safesitellc.com","lennyg@safesitellc.com","suzannep@safesitellc.com","thomasc@safesitellc.com","travisf@safesitellc.com"],"ResultSize":500}}}'
|
||||
echo ","
|
||||
echo "\"message_trace_mparis\":"
|
||||
inv "$EOP" '{"CmdletInput":{"CmdletName":"Get-MessageTraceV2","Parameters":{"SenderAddress":"m.paris@nexsitepartners.com","StartDate":"2026-06-08T00:00:00","EndDate":"2026-06-09T00:00:00"}}}'
|
||||
echo "}"
|
||||
} > "$OUT" 2>&1
|
||||
echo "[poller] DONE -> $OUT"
|
||||
echo "[poller] quick tally:"
|
||||
echo " audit FreeText 'SSUS 06122026' rows: $(jq '.audit_freetext_SSUS.value|length' "$OUT" 2>/dev/null || echo '?')"
|
||||
echo " audit delete/purge rows (recipients): $(jq '.audit_deletes_recipients.value|length' "$OUT" 2>/dev/null || echo '?')"
|
||||
exit 0
|
||||
fi
|
||||
sleep 300
|
||||
done
|
||||
echo "[poller] EXO still not ready after 75 min — coord todo 7ddc8ebd remains for a later session."
|
||||
exit 0
|
||||
194
.claude/scripts/cdp.py
Normal file
194
.claude/scripts/cdp.py
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
cdp.py - drive Chrome over the DevTools Protocol (CDP), like Antigravity does.
|
||||
|
||||
Launches (or attaches to) a Chrome started with --remote-debugging-port and drives
|
||||
it: navigate, screenshot-to-disk, click, type, key, eval. Screenshots are written
|
||||
as real PNG files (so they can be fed to Gemini/Grok image tools).
|
||||
|
||||
Usage:
|
||||
py cdp.py launch [url] # start a visible debug Chrome (dedicated profile)
|
||||
py cdp.py status # /json/version + list page targets
|
||||
py cdp.py nav <url> [tabid] # navigate (active page if tabid omitted)
|
||||
py cdp.py shot <out.png> [tabid] # screenshot the page to a PNG file
|
||||
py cdp.py click <x> <y> [tabid] # left-click at viewport coords
|
||||
py cdp.py type <text> [tabid] # insert text into the focused element
|
||||
py cdp.py key <Key> [tabid] # press a key (Enter/Tab/Escape/...)
|
||||
py cdp.py eval <js> [tabid] # Runtime.evaluate, prints JSON result
|
||||
|
||||
Env: CDP_PORT (default 9222), CDP_PROFILE (default %USERPROFILE%\\.claude\\cdp-chrome-profile)
|
||||
"""
|
||||
import sys, os, json, time, base64, subprocess, urllib.request
|
||||
|
||||
PORT = int(os.environ.get("CDP_PORT", "9222"))
|
||||
BASE = f"http://localhost:{PORT}"
|
||||
PROFILE = os.environ.get("CDP_PROFILE", os.path.join(os.path.expanduser("~"), ".claude", "cdp-chrome-profile"))
|
||||
CHROME = next((p for p in [
|
||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
|
||||
] if os.path.isfile(p)), None)
|
||||
|
||||
import websocket # websocket-client
|
||||
|
||||
|
||||
def http_get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=5) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def page_targets():
|
||||
return [t for t in http_get("/json") if t.get("type") == "page"]
|
||||
|
||||
|
||||
def pick_target(tabid=None):
|
||||
targets = page_targets()
|
||||
if not targets:
|
||||
raise SystemExit("[cdp] no page targets. Run: py cdp.py launch")
|
||||
if tabid:
|
||||
for t in targets:
|
||||
if t["id"] == tabid:
|
||||
return t
|
||||
raise SystemExit(f"[cdp] tabid {tabid} not found")
|
||||
# prefer a non-devtools, non-blank page
|
||||
for t in targets:
|
||||
if not t["url"].startswith("devtools://"):
|
||||
return t
|
||||
return targets[0]
|
||||
|
||||
|
||||
def send(ws, _id, method, params=None):
|
||||
ws.send(json.dumps({"id": _id, "method": method, "params": params or {}}))
|
||||
while True:
|
||||
msg = json.loads(ws.recv())
|
||||
if msg.get("id") == _id:
|
||||
if "error" in msg:
|
||||
raise SystemExit(f"[cdp] {method} error: {msg['error']}")
|
||||
return msg.get("result", {})
|
||||
# ignore events with no matching id
|
||||
|
||||
|
||||
def with_ws(tabid, fn):
|
||||
t = pick_target(tabid)
|
||||
ws = websocket.create_connection(t["webSocketDebuggerUrl"], max_size=64 * 1024 * 1024)
|
||||
try:
|
||||
return fn(ws)
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
|
||||
def cmd_launch(args):
|
||||
if not CHROME:
|
||||
raise SystemExit("[cdp] chrome.exe not found")
|
||||
os.makedirs(PROFILE, exist_ok=True)
|
||||
url = args[0] if args else "about:blank"
|
||||
subprocess.Popen([
|
||||
CHROME,
|
||||
f"--remote-debugging-port={PORT}",
|
||||
f"--user-data-dir={PROFILE}",
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--remote-allow-origins=*",
|
||||
url,
|
||||
], close_fds=True)
|
||||
for _ in range(40):
|
||||
try:
|
||||
v = http_get("/json/version")
|
||||
print(f"[cdp] launched: {v.get('Browser')} ws={v.get('webSocketDebuggerUrl','')[:40]}...")
|
||||
print(f"[cdp] profile: {PROFILE}")
|
||||
return
|
||||
except Exception:
|
||||
time.sleep(0.25)
|
||||
raise SystemExit("[cdp] chrome started but debug port never opened")
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
v = http_get("/json/version")
|
||||
print(f"Browser: {v.get('Browser')}")
|
||||
for t in page_targets():
|
||||
print(f" [{t['id'][:8]}] {t['title'][:40]!r} {t['url'][:70]}")
|
||||
|
||||
|
||||
def cmd_nav(args):
|
||||
url = args[0]
|
||||
if "://" not in url:
|
||||
url = "https://" + url
|
||||
tabid = args[1] if len(args) > 1 else None
|
||||
def fn(ws):
|
||||
send(ws, 1, "Page.enable")
|
||||
send(ws, 2, "Page.navigate", {"url": url})
|
||||
# wait for load event (best-effort)
|
||||
deadline = time.time() + 20
|
||||
ws.settimeout(20)
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
m = json.loads(ws.recv())
|
||||
except Exception:
|
||||
break
|
||||
if m.get("method") == "Page.loadEventFired":
|
||||
break
|
||||
return "ok"
|
||||
with_ws(tabid, fn)
|
||||
time.sleep(1.0)
|
||||
print(f"[cdp] navigated -> {url}")
|
||||
|
||||
|
||||
def cmd_shot(args):
|
||||
out = os.path.abspath(args[0])
|
||||
tabid = args[1] if len(args) > 1 else None
|
||||
def fn(ws):
|
||||
return send(ws, 1, "Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})
|
||||
res = with_ws(tabid, fn)
|
||||
with open(out, "wb") as f:
|
||||
f.write(base64.b64decode(res["data"]))
|
||||
print(f"[cdp] screenshot -> {out} ({os.path.getsize(out)} bytes)")
|
||||
|
||||
|
||||
def cmd_click(args):
|
||||
x, y = float(args[0]), float(args[1])
|
||||
tabid = args[2] if len(args) > 2 else None
|
||||
def fn(ws):
|
||||
for typ in ("mousePressed", "mouseReleased"):
|
||||
send(ws, 1, "Input.dispatchMouseEvent",
|
||||
{"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1})
|
||||
return "ok"
|
||||
with_ws(tabid, fn)
|
||||
print(f"[cdp] click ({x},{y})")
|
||||
|
||||
|
||||
def cmd_type(args):
|
||||
text = args[0]
|
||||
tabid = args[1] if len(args) > 1 else None
|
||||
with_ws(tabid, lambda ws: send(ws, 1, "Input.insertText", {"text": text}))
|
||||
print(f"[cdp] typed {len(text)} chars")
|
||||
|
||||
|
||||
KEYMAP = {"Enter": 13, "Return": 13, "Tab": 9, "Escape": 27, "Backspace": 8}
|
||||
def cmd_key(args):
|
||||
key = args[0]
|
||||
tabid = args[1] if len(args) > 1 else None
|
||||
code = KEYMAP.get(key)
|
||||
def fn(ws):
|
||||
base = {"key": key, "windowsVirtualKeyCode": code} if code else {"key": key}
|
||||
send(ws, 1, "Input.dispatchKeyEvent", {"type": "keyDown", **base})
|
||||
send(ws, 2, "Input.dispatchKeyEvent", {"type": "keyUp", **base})
|
||||
return "ok"
|
||||
with_ws(tabid, fn)
|
||||
print(f"[cdp] key {key}")
|
||||
|
||||
|
||||
def cmd_eval(args):
|
||||
js = args[0]
|
||||
tabid = args[1] if len(args) > 1 else None
|
||||
res = with_ws(tabid, lambda ws: send(ws, 1, "Runtime.evaluate",
|
||||
{"expression": js, "returnByValue": True}))
|
||||
print(json.dumps(res.get("result", {}).get("value"), indent=2, default=str))
|
||||
|
||||
|
||||
CMDS = {"launch": cmd_launch, "status": cmd_status, "nav": cmd_nav, "shot": cmd_shot,
|
||||
"click": cmd_click, "type": cmd_type, "key": cmd_key, "eval": cmd_eval}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
|
||||
print(__doc__)
|
||||
raise SystemExit(1)
|
||||
CMDS[sys.argv[1]](sys.argv[2:])
|
||||
279
.claude/scripts/ff.py
Normal file
279
.claude/scripts/ff.py
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
ff.py - drive Firefox over Playwright, the Firefox sibling of cdp.py.
|
||||
|
||||
Firefox dropped most of its CDP support, so the stateless "new connection per
|
||||
command" trick cdp.py uses against Chrome's debug port doesn't port cleanly.
|
||||
Instead `launch` spawns a small background daemon that holds ONE Playwright
|
||||
Firefox page (on a persistent profile, so logins survive); every other
|
||||
subcommand is a thin HTTP client to that daemon. The page persists between
|
||||
calls (nav now, shot later) and the daemon accumulates console + network logs
|
||||
for retrieval -- the "collect the logs" use case.
|
||||
|
||||
Usage:
|
||||
py ff.py launch [url] [--headless] # start the background Firefox daemon
|
||||
py ff.py status # daemon health + current url/title
|
||||
py ff.py nav <url> # navigate the page
|
||||
py ff.py shot <out.png> # screenshot the page to a PNG file
|
||||
py ff.py click <x> <y> # left-click at viewport coords
|
||||
py ff.py type <text> # insert text into the focused element
|
||||
py ff.py key <Key> # press a key (Enter/Tab/Escape/...)
|
||||
py ff.py eval <js> # page.evaluate(js), prints JSON result
|
||||
py ff.py console [--clear] # dump collected console messages (JSON)
|
||||
py ff.py network [--clear] # dump collected network requests (JSON)
|
||||
py ff.py stop # shut the daemon down
|
||||
|
||||
Env: FF_PORT (control port, default 9333)
|
||||
FF_PROFILE (default %USERPROFILE%\\.claude\\ff-profile)
|
||||
"""
|
||||
import sys, os, json, time, subprocess, urllib.request, urllib.error
|
||||
|
||||
PORT = int(os.environ.get("FF_PORT", "9333"))
|
||||
BASE = f"http://localhost:{PORT}"
|
||||
PROFILE = os.environ.get("FF_PROFILE", os.path.join(os.path.expanduser("~"), ".claude", "ff-profile"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# client side (the CLI you actually type)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _req(path, method="GET", body=None, timeout=30):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
r = urllib.request.Request(BASE + path, data=data, method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(r, timeout=timeout) as resp:
|
||||
raw = resp.read().decode()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def _alive():
|
||||
try:
|
||||
_req("/status", timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_launch(args):
|
||||
headless = "--headless" in args
|
||||
url = next((a for a in args if not a.startswith("--")), None)
|
||||
if _alive():
|
||||
print(f"[ff] daemon already running on {BASE}")
|
||||
if url:
|
||||
_req("/nav", "POST", {"url": _fix(url)})
|
||||
print(f"[ff] navigated -> {_fix(url)}")
|
||||
return
|
||||
os.makedirs(PROFILE, exist_ok=True)
|
||||
flags = subprocess.CREATE_NEW_PROCESS_GROUP | 0x00000008 # DETACHED_PROCESS
|
||||
env = dict(os.environ, FF_DAEMON="1", FF_HEADLESS="1" if headless else "0",
|
||||
FF_START_URL=_fix(url) if url else "about:blank")
|
||||
# Redirect the detached child's stdio to a logfile -- otherwise it inherits
|
||||
# the parent's stdout pipe (caller never gets control back) and any startup
|
||||
# crash is invisible.
|
||||
log = open(os.path.join(os.path.dirname(PROFILE), "ff-daemon.log"), "w")
|
||||
subprocess.Popen([sys.executable, os.path.abspath(__file__), "_serve"],
|
||||
env=env, creationflags=flags, close_fds=True,
|
||||
stdin=subprocess.DEVNULL, stdout=log, stderr=log)
|
||||
for _ in range(60):
|
||||
if _alive():
|
||||
print(f"[ff] daemon up on {BASE} (headless={headless}) profile={PROFILE}")
|
||||
if url:
|
||||
print(f"[ff] start url -> {_fix(url)}")
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise SystemExit("[ff] daemon failed to start (check that 'py -m playwright install firefox' ran)")
|
||||
|
||||
|
||||
def _fix(url):
|
||||
if url and "://" not in url and url != "about:blank":
|
||||
return "https://" + url
|
||||
return url
|
||||
|
||||
|
||||
def _need(args, n, what):
|
||||
if len(args) < n:
|
||||
raise SystemExit(f"[ff] {what}")
|
||||
|
||||
|
||||
def cmd_status(a):
|
||||
print(json.dumps(_req("/status"), indent=2))
|
||||
|
||||
|
||||
def cmd_nav(a):
|
||||
_need(a, 1, "usage: ff.py nav <url>")
|
||||
_req("/nav", "POST", {"url": _fix(a[0])})
|
||||
print(f"[ff] navigated -> {_fix(a[0])}")
|
||||
|
||||
|
||||
def cmd_shot(a):
|
||||
_need(a, 1, "usage: ff.py shot <out.png>")
|
||||
out = os.path.abspath(a[0])
|
||||
_req("/shot", "POST", {"path": out})
|
||||
print(f"[ff] screenshot -> {out} ({os.path.getsize(out)} bytes)")
|
||||
|
||||
|
||||
def cmd_click(a):
|
||||
_need(a, 2, "usage: ff.py click <x> <y>")
|
||||
_req("/click", "POST", {"x": float(a[0]), "y": float(a[1])})
|
||||
print(f"[ff] click ({a[0]},{a[1]})")
|
||||
|
||||
|
||||
def cmd_type(a):
|
||||
_need(a, 1, "usage: ff.py type <text>")
|
||||
_req("/type", "POST", {"text": a[0]})
|
||||
print(f"[ff] typed {len(a[0])} chars")
|
||||
|
||||
|
||||
def cmd_key(a):
|
||||
_need(a, 1, "usage: ff.py key <Key>")
|
||||
_req("/key", "POST", {"key": a[0]})
|
||||
print(f"[ff] key {a[0]}")
|
||||
|
||||
|
||||
def cmd_eval(a):
|
||||
_need(a, 1, "usage: ff.py eval <js>")
|
||||
print(json.dumps(_req("/eval", "POST", {"js": a[0]}).get("value"), indent=2, default=str))
|
||||
|
||||
|
||||
def cmd_console(a):
|
||||
res = _req("/console" + ("?clear=1" if "--clear" in a else ""))
|
||||
print(json.dumps(res.get("messages", []), indent=2, default=str))
|
||||
|
||||
|
||||
def cmd_network(a):
|
||||
res = _req("/network" + ("?clear=1" if "--clear" in a else ""))
|
||||
print(json.dumps(res.get("requests", []), indent=2, default=str))
|
||||
|
||||
|
||||
def cmd_stop(a):
|
||||
if not _alive():
|
||||
print("[ff] daemon not running")
|
||||
return
|
||||
try:
|
||||
_req("/stop", "POST", {}, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
print("[ff] daemon stopped")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# daemon side (py ff.py _serve) -- holds the live Firefox page
|
||||
# --------------------------------------------------------------------------- #
|
||||
def serve():
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import threading
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
headless = os.environ.get("FF_HEADLESS") == "1"
|
||||
start_url = os.environ.get("FF_START_URL", "about:blank")
|
||||
|
||||
pw = sync_playwright().start()
|
||||
ctx = pw.firefox.launch_persistent_context(PROFILE, headless=headless,
|
||||
viewport={"width": 1280, "height": 800})
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
|
||||
console_log, network_log = [], []
|
||||
page.on("console", lambda m: console_log.append(
|
||||
{"type": m.type, "text": m.text, "location": m.location}))
|
||||
page.on("response", lambda r: network_log.append(
|
||||
{"status": r.status, "method": r.request.method, "url": r.url,
|
||||
"type": r.request.resource_type}))
|
||||
page.on("pageerror", lambda e: console_log.append(
|
||||
{"type": "pageerror", "text": str(e), "location": {}}))
|
||||
if start_url and start_url != "about:blank":
|
||||
try:
|
||||
page.goto(start_url, wait_until="load", timeout=30000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): # silence
|
||||
pass
|
||||
|
||||
def _reply(self, obj, code=200):
|
||||
b = json.dumps(obj, default=str).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
def _body(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
return json.loads(self.rfile.read(n)) if n else {}
|
||||
|
||||
def do_GET(self):
|
||||
u = urlparse(self.path)
|
||||
q = parse_qs(u.query)
|
||||
try:
|
||||
if u.path == "/status":
|
||||
self._reply({"ok": True, "url": page.url, "title": page.title(),
|
||||
"headless": headless, "console": len(console_log),
|
||||
"network": len(network_log)})
|
||||
elif u.path == "/console":
|
||||
msgs = list(console_log)
|
||||
if q.get("clear"):
|
||||
console_log.clear()
|
||||
self._reply({"messages": msgs})
|
||||
elif u.path == "/network":
|
||||
reqs = list(network_log)
|
||||
if q.get("clear"):
|
||||
network_log.clear()
|
||||
self._reply({"requests": reqs})
|
||||
else:
|
||||
self._reply({"error": "not found"}, 404)
|
||||
except Exception as e:
|
||||
self._reply({"error": str(e)}, 500)
|
||||
|
||||
def do_POST(self):
|
||||
u = urlparse(self.path)
|
||||
try:
|
||||
b = self._body()
|
||||
if u.path == "/nav":
|
||||
page.goto(b["url"], wait_until="load", timeout=30000)
|
||||
self._reply({"ok": True, "url": page.url})
|
||||
elif u.path == "/shot":
|
||||
page.screenshot(path=b["path"], full_page=b.get("full", False))
|
||||
self._reply({"ok": True})
|
||||
elif u.path == "/click":
|
||||
page.mouse.click(b["x"], b["y"])
|
||||
self._reply({"ok": True})
|
||||
elif u.path == "/type":
|
||||
page.keyboard.insert_text(b["text"])
|
||||
self._reply({"ok": True})
|
||||
elif u.path == "/key":
|
||||
page.keyboard.press(b["key"])
|
||||
self._reply({"ok": True})
|
||||
elif u.path == "/eval":
|
||||
self._reply({"value": page.evaluate(b["js"])})
|
||||
elif u.path == "/stop":
|
||||
self._reply({"ok": True})
|
||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||
else:
|
||||
self._reply({"error": "not found"}, 404)
|
||||
except Exception as e:
|
||||
self._reply({"error": str(e)}, 500)
|
||||
|
||||
httpd = HTTPServer(("127.0.0.1", PORT), H)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
finally:
|
||||
try:
|
||||
ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
pw.stop()
|
||||
|
||||
|
||||
CMDS = {"launch": cmd_launch, "status": cmd_status, "nav": cmd_nav, "shot": cmd_shot,
|
||||
"click": cmd_click, "type": cmd_type, "key": cmd_key, "eval": cmd_eval,
|
||||
"console": cmd_console, "network": cmd_network, "stop": cmd_stop}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_serve":
|
||||
serve()
|
||||
elif len(sys.argv) < 2 or sys.argv[1] not in CMDS:
|
||||
print(__doc__)
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
CMDS[sys.argv[1]](sys.argv[2:])
|
||||
31
.claude/scripts/force-pull-raw.sh
Normal file
31
.claude/scripts/force-pull-raw.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# OOB harness recovery. Rescues a node whose normal /sync or /save is broken by a bad
|
||||
# harness change. Hook-free, guard-free, minimal deps. Resets the ClaudeTools repo to
|
||||
# origin/main. Does NOT touch the vault or submodules.
|
||||
#
|
||||
# bash .claude/scripts/force-pull-raw.sh # dry-run: show what would change
|
||||
# bash .claude/scripts/force-pull-raw.sh --confirm # hard-reset to origin/main
|
||||
#
|
||||
# --confirm first saves your current HEAD to a local branch recovery/pre-force-pull-<sha>
|
||||
# so no committed work is truly lost.
|
||||
set -uo pipefail
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "[ERROR] not in a git repo"; exit 1; }
|
||||
cd "$ROOT"
|
||||
echo "[force-pull-raw] repo: $ROOT"
|
||||
if ! git fetch origin 2>&1 | tail -2; then echo "[ERROR] git fetch origin failed"; exit 1; fi
|
||||
LOCAL=$(git rev-parse --short HEAD 2>/dev/null)
|
||||
REMOTE=$(git rev-parse --short origin/main 2>/dev/null)
|
||||
echo "--- local HEAD: $LOCAL | origin/main: $REMOTE ---"
|
||||
echo "--- working-tree changes a hard reset would discard ---"
|
||||
git status --short
|
||||
echo "--- local-only commits a hard reset would discard ---"
|
||||
git log --oneline origin/main..HEAD 2>/dev/null | head
|
||||
if [ "${1:-}" != "--confirm" ]; then
|
||||
echo ""
|
||||
echo "DRY RUN. Re-run with --confirm to hard-reset to origin/main (discards the above;"
|
||||
echo "current HEAD will be saved to a local recovery branch first)."
|
||||
exit 0
|
||||
fi
|
||||
git branch -f "recovery/pre-force-pull-$LOCAL" HEAD 2>/dev/null || true
|
||||
git reset --hard origin/main
|
||||
echo "[OK] reset to origin/main ($REMOTE). Prior HEAD saved at recovery/pre-force-pull-$LOCAL"
|
||||
67
.claude/scripts/harness-guard.sh
Normal file
67
.claude/scripts/harness-guard.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# Harness commit guard. Inspects STAGED content for footguns before a commit.
|
||||
#
|
||||
# Rollout posture: WARN-ONLY by default (logs + prints, never blocks). This is
|
||||
# deliberate (Task 4): a guard that fails closed can brick every machine's /save. It is
|
||||
# promoted to blocking only after a clean warn window across the fleet.
|
||||
# - default -> warn only, exit 0
|
||||
# - HARNESS_GUARD_FATAL=1 -> exit 1 on any issue (caller decides to abort)
|
||||
# - SKIP_HARNESS_GUARD=1 -> bypass entirely (logged)
|
||||
# Detects: conflict markers, unencrypted SOPS / private-key material, and a staged
|
||||
# submodule gitlink change (informational).
|
||||
set -uo pipefail
|
||||
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
cd "$ROOT"
|
||||
LOG="$ROOT/.claude/harness/guard.log"
|
||||
mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
|
||||
ts() { date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "?"; }
|
||||
warn() { echo "[harness-guard][WARN] $1"; echo "$(ts) WARN $1" >> "$LOG" 2>/dev/null || true; }
|
||||
|
||||
if [ "${SKIP_HARNESS_GUARD:-0}" = "1" ]; then
|
||||
echo "[harness-guard] bypassed (SKIP_HARNESS_GUARD=1)"
|
||||
echo "$(ts) BYPASS SKIP_HARNESS_GUARD=1" >> "$LOG" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ISSUES=0
|
||||
mapfile -t STAGED < <(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)
|
||||
|
||||
for f in "${STAGED[@]}"; do
|
||||
[ -n "$f" ] || continue
|
||||
blob=$(git show ":$f" 2>/dev/null) || continue
|
||||
# 1. Conflict markers — require a REAL hunk: both an open (<<<<<<<) AND a close
|
||||
# (>>>>>>>) marker at line start. A lone '=======' line is a markdown setext
|
||||
# underline or a divider, not a conflict, so flagging it alone is a false positive
|
||||
# with no detection value (git always writes all three markers). Requiring the pair
|
||||
# eliminates that vector (verified by test-harness-guard.sh) before FATAL promotion.
|
||||
if printf '%s\n' "$blob" | grep -qE '^<<<<<<< ' && printf '%s\n' "$blob" | grep -qE '^>>>>>>> '; then
|
||||
warn "conflict markers in staged file: $f"; ISSUES=$((ISSUES + 1))
|
||||
fi
|
||||
# 2. Unencrypted SOPS vault file
|
||||
case "$f" in
|
||||
*.sops.yaml|*.sops.json|*.sops.env)
|
||||
if ! printf '%s\n' "$blob" | grep -qE 'ENC\[|^sops:'; then
|
||||
warn "possible UNENCRYPTED sops file staged: $f"; ISSUES=$((ISSUES + 1))
|
||||
fi ;;
|
||||
esac
|
||||
# 3. Private key material
|
||||
if printf '%s\n' "$blob" | grep -qE -- '-----BEGIN [A-Z ]*PRIVATE KEY-----'; then
|
||||
warn "private-key material in staged file: $f"; ISSUES=$((ISSUES + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Submodule gitlink staged (informational — should only happen with --with-submodules)
|
||||
if git diff --cached --submodule=short 2>/dev/null | grep -q '^Submodule '; then
|
||||
warn "submodule gitlink change is staged (intentional only via --with-submodules)"
|
||||
fi
|
||||
|
||||
if [ "$ISSUES" -gt 0 ]; then
|
||||
echo "[harness-guard] $ISSUES issue(s) found."
|
||||
if [ "${HARNESS_GUARD_FATAL:-0}" = "1" ]; then
|
||||
echo "[harness-guard] FATAL mode -> signalling block."
|
||||
exit 1
|
||||
fi
|
||||
echo "[harness-guard] WARN-ONLY mode -> not blocking."
|
||||
fi
|
||||
exit 0
|
||||
@@ -96,6 +96,28 @@ else
|
||||
echo " Grok: not installed"
|
||||
fi
|
||||
|
||||
# Detect Google Gemini CLI — optional capability extension (independent second
|
||||
# model: verify / review / text). Sibling of Grok. Per-machine; sets identity
|
||||
# gemini.installed so the /agy skill knows whether it can run locally. Does NOT
|
||||
# set is_fleet_host (manual fleet-coordination choice, preserved if present).
|
||||
GEMINI_BIN=""
|
||||
if command -v gemini >/dev/null 2>&1; then
|
||||
GEMINI_BIN="$(command -v gemini)"
|
||||
else
|
||||
for c in "${APPDATA:-}/npm/gemini" "$HOME/AppData/Roaming/npm/gemini" \
|
||||
"/usr/local/bin/gemini" "$HOME/.npm-global/bin/gemini"; do
|
||||
if [ -n "$c" ] && [ -x "$c" ]; then GEMINI_BIN="$c"; break; fi
|
||||
done
|
||||
fi
|
||||
if [ -n "$GEMINI_BIN" ]; then
|
||||
GEMINI_BIN="$(cygpath -m "$GEMINI_BIN" 2>/dev/null || echo "$GEMINI_BIN")"
|
||||
GEMINI_INSTALLED="true"
|
||||
echo " Gemini: installed ($GEMINI_BIN)"
|
||||
else
|
||||
GEMINI_INSTALLED="false"
|
||||
echo " Gemini: not installed"
|
||||
fi
|
||||
|
||||
# Build updated identity.json
|
||||
echo ""
|
||||
echo "[INFO] Updating identity.json..."
|
||||
@@ -136,6 +158,17 @@ else:
|
||||
g['installed'] = False
|
||||
data['grok'] = g
|
||||
|
||||
# Gemini capability flag (per-machine, sibling of grok). Preserve manual is_fleet_host.
|
||||
gm = data.get('gemini') or {}
|
||||
if '$GEMINI_INSTALLED' == 'true':
|
||||
gm['installed'] = True
|
||||
gm['binary'] = r'$GEMINI_BIN'
|
||||
gm.setdefault('auth', 'oauth')
|
||||
gm['capabilities'] = ['text', 'verify', 'review', 'image-analyze', 'search']
|
||||
else:
|
||||
gm['installed'] = False
|
||||
data['gemini'] = gm
|
||||
|
||||
# Coord API endpoint — populate only if absent so existing machines keep their override.
|
||||
if 'coord_api' not in data:
|
||||
data['coord_api'] = '$COORD_API_DEFAULT'
|
||||
@@ -158,6 +191,7 @@ echo " ollama.prose_model: $PROSE_MODEL"
|
||||
echo " platform: $PLATFORM"
|
||||
echo " architecture: $ARCH"
|
||||
echo " grok.installed: $GROK_INSTALLED"
|
||||
echo " gemini.installed: $GEMINI_INSTALLED"
|
||||
echo " coord_api: (default $COORD_API_DEFAULT if not already set)"
|
||||
echo ""
|
||||
echo "Review: cat $IDENTITY_PATH"
|
||||
|
||||
50
.claude/scripts/now-phoenix.sh
Normal file
50
.claude/scripts/now-phoenix.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# now-phoenix.sh — emit the current America/Phoenix timestamp, deterministically.
|
||||
#
|
||||
# WHY: `TZ=America/Phoenix date` is unreliable on Git-for-Windows bash (the MSYS
|
||||
# tz database is often absent, so it silently returns UTC). Arizona does NOT
|
||||
# observe DST — it is fixed UTC-7 (MST) year-round — so we compute Phoenix time
|
||||
# as (UTC epoch - 7h) and format it. No tz database, no DST edge cases, identical
|
||||
# result on Windows / macOS / Linux.
|
||||
#
|
||||
# Usage:
|
||||
# bash now-phoenix.sh -> 2026-06-08 14:32 PT (default, human log line)
|
||||
# bash now-phoenix.sh --iso -> 2026-06-08T14:32:07-07:00
|
||||
# bash now-phoenix.sh --date -> 2026-06-08
|
||||
# bash now-phoenix.sh --datetime -> 2026-06-08 14:32:07
|
||||
# bash now-phoenix.sh --epoch -> 1749422327 (raw UTC epoch, for arithmetic)
|
||||
# bash now-phoenix.sh --fmt '+%H:%M' -> 14:32 (custom strftime, applied to Phoenix time)
|
||||
#
|
||||
# All output is on stdout, no trailing prose. Soft, dependency-free (coreutils date only).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OFFSET=$((7 * 3600)) # Phoenix is UTC-7, fixed
|
||||
EPOCH_UTC="$(date -u +%s)"
|
||||
EPOCH_PHX=$((EPOCH_UTC - OFFSET))
|
||||
|
||||
# Portable "format an epoch as if it were UTC" (so the wall-clock we print is Phoenix local).
|
||||
fmt_epoch() {
|
||||
local e="$1" f="$2"
|
||||
if date -u -d "@${e}" "$f" >/dev/null 2>&1; then
|
||||
date -u -d "@${e}" "$f" # GNU/Git-Bash
|
||||
else
|
||||
date -u -r "${e}" "$f" # BSD/macOS
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--iso) printf '%s-07:00\n' "$(fmt_epoch "$EPOCH_PHX" '+%Y-%m-%dT%H:%M:%S')" ;;
|
||||
--date) fmt_epoch "$EPOCH_PHX" '+%Y-%m-%d' ;;
|
||||
--datetime) fmt_epoch "$EPOCH_PHX" '+%Y-%m-%d %H:%M:%S' ;;
|
||||
--epoch) printf '%s\n' "$EPOCH_UTC" ;;
|
||||
--fmt) fmt_epoch "$EPOCH_PHX" "${2:?--fmt needs a strftime arg, e.g. --fmt '+%H:%M'}" ;;
|
||||
''|--pt) printf '%s PT\n' "$(fmt_epoch "$EPOCH_PHX" '+%Y-%m-%d %H:%M')" ;;
|
||||
-h|--help)
|
||||
grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] now-phoenix: unknown arg '$1' (try --help)" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
56
.claude/scripts/rmm-auth.sh
Executable file
56
.claude/scripts/rmm-auth.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# rmm-auth.sh - Get GuruRMM authentication token
|
||||
# Outputs: TOKEN RMM_URL REPO_ROOT (space-separated)
|
||||
# Usage: eval "$(bash .claude/scripts/rmm-auth.sh)"
|
||||
# This sets: $TOKEN, $RMM, $REPO_ROOT in the calling shell
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve paths
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
IDENTITY_FILE="$REPO_ROOT/.claude/identity.json"
|
||||
|
||||
if [ ! -f "$IDENTITY_FILE" ]; then
|
||||
echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] identity.json not found' >&2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VAULT_PATH=$(jq -r '.vault_path // empty' "$IDENTITY_FILE")
|
||||
if [ -z "$VAULT_PATH" ]; then
|
||||
echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] vault_path not in identity.json' >&2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VAULT_SH="$VAULT_PATH/scripts/vault.sh"
|
||||
if [ ! -f "$VAULT_SH" ]; then
|
||||
echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] vault.sh not found at $VAULT_SH' >&2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RMM_URL="http://172.16.3.30:3001"
|
||||
|
||||
# Get credentials
|
||||
RMM_EMAIL=$(bash "$VAULT_SH" get-field infrastructure/gururmm-server.sops.yaml credentials.gururmm-api.admin-email 2>/dev/null)
|
||||
RMM_PASS=$(bash "$VAULT_SH" get-field infrastructure/gururmm-server.sops.yaml credentials.gururmm-api.admin-password 2>/dev/null)
|
||||
|
||||
if [ -z "$RMM_EMAIL" ] || [ -z "$RMM_PASS" ]; then
|
||||
echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] Failed to get RMM credentials from vault' >&2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Login - use jq to build JSON safely
|
||||
PAYLOAD=$(jq -n --arg email "$RMM_EMAIL" --arg password "$RMM_PASS" '{email: $email, password: $password}')
|
||||
JWT=$(curl -s -X POST "$RMM_URL/api/auth/login" -H "Content-Type: application/json" -d "$PAYLOAD")
|
||||
TOKEN=$(echo "$JWT" | jq -r '.token // empty')
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] RMM login failed: $JWT' >&2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Output exports for eval
|
||||
echo "export TOKEN='$TOKEN'"
|
||||
echo "export RMM='$RMM_URL'"
|
||||
echo "export REPO_ROOT='$REPO_ROOT'"
|
||||
echo "echo '[OK] Authenticated to GuruRMM' >&2"
|
||||
@@ -218,7 +218,16 @@ REMOTE_PS1="\$env:TEMP\\${REMOTE_TAG}.ps1"
|
||||
|
||||
# Produce base64 (single line) and split into chunks.
|
||||
B64_FILE="$WORK_DIR/probe.b64"
|
||||
base64 -w0 "$PROBE" > "$B64_FILE" 2>/dev/null || base64 "$PROBE" | tr -d '\n' > "$B64_FILE"
|
||||
# macOS (BSD) base64 uses -i for input file and has no line-wrap flag (outputs single line by default).
|
||||
# GNU base64 accepts file as positional arg and uses -w0 for no wrap.
|
||||
if base64 -i "$PROBE" > "$B64_FILE" 2>/dev/null; then
|
||||
: # macOS/BSD path succeeded
|
||||
elif base64 -w0 "$PROBE" > "$B64_FILE" 2>/dev/null; then
|
||||
: # GNU path succeeded
|
||||
else
|
||||
# Fallback: stdin input, strip newlines
|
||||
base64 < "$PROBE" | tr -d '\n' > "$B64_FILE"
|
||||
fi
|
||||
CHUNK_DIR="$WORK_DIR/chunks"
|
||||
mkdir -p "$CHUNK_DIR"
|
||||
split -b 24000 "$B64_FILE" "$CHUNK_DIR/chunk_"
|
||||
|
||||
102
.claude/scripts/setup-git-auth.sh
Normal file
102
.claude/scripts/setup-git-auth.sh
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-git-auth.sh — make git push/fetch fully non-interactive on this machine.
|
||||
#
|
||||
# Mike's requirement: git must NEVER sit at an interactive credential prompt
|
||||
# (Git Credential Manager popups hang automation/background pushes). This script
|
||||
# primes the git "store" credential helper with the shared azcomputerguru Gitea
|
||||
# API token (from the SOPS vault), scoped to each repo's actual remote host.
|
||||
#
|
||||
# Properties:
|
||||
# - Idempotent + fast-path: if every managed repo already has a stored
|
||||
# credential for its remote host, it exits WITHOUT touching the vault.
|
||||
# - Conservative: only switches a repo to the `store` helper when the current
|
||||
# helper is empty or the prompting GCM `manager` (so a Mac osxkeychain setup
|
||||
# that already works silently is left untouched).
|
||||
# - Fail-silent: always exits 0; never blocks a session.
|
||||
#
|
||||
# Runs from the SessionStart hook (backgrounded) and from onboarding.
|
||||
# See: .claude/memory/feedback_git_noninteractive_auth.md
|
||||
|
||||
set -u
|
||||
|
||||
# --- locate repo root + identity ------------------------------------------------
|
||||
CT_ROOT="${CLAUDE_PROJECT_DIR:-}"
|
||||
if [ -z "$CT_ROOT" ]; then
|
||||
# two levels up from this script: .claude/scripts/ -> repo root
|
||||
CT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
|
||||
fi
|
||||
IDENTITY="$CT_ROOT/.claude/identity.json"
|
||||
VAULT="$CT_ROOT/.claude/scripts/vault.sh"
|
||||
CRED_FILE="$HOME/.git-credentials"
|
||||
GIT_USER="azcomputerguru"
|
||||
|
||||
# Extract a flat string field from identity.json without requiring jq.
|
||||
json_field() { grep -oE "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$IDENTITY" 2>/dev/null | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/'; }
|
||||
|
||||
VAULT_PATH="$(json_field vault_path)"
|
||||
|
||||
# Candidate repos to make non-interactive: this repo + the vault repo.
|
||||
REPOS=("$CT_ROOT")
|
||||
[ -n "$VAULT_PATH" ] && [ -d "$VAULT_PATH/.git" ] && REPOS+=("$VAULT_PATH")
|
||||
|
||||
# --- derive scheme + host (authority) from a remote URL -------------------------
|
||||
remote_authority() { # echoes "scheme host[:port]" or nothing
|
||||
local url="$1" scheme rest auth host
|
||||
case "$url" in
|
||||
http://*|https://*) scheme="${url%%://*}";;
|
||||
*) return 0;; # ssh/git@ remotes don't use the credential store
|
||||
esac
|
||||
rest="${url#*://}"
|
||||
auth="${rest%%/*}" # strip path
|
||||
host="${auth##*@}" # strip any userinfo
|
||||
[ -n "$host" ] && printf '%s %s' "$scheme" "$host"
|
||||
}
|
||||
|
||||
# Does the cred file already have an entry for this scheme://user@host ?
|
||||
have_cred() { # $1=scheme $2=host
|
||||
[ -f "$CRED_FILE" ] || return 1
|
||||
grep -qE "^$1://$GIT_USER:[^@]*@$2$" "$CRED_FILE" 2>/dev/null
|
||||
}
|
||||
|
||||
# --- fast path: everything already configured? ---------------------------------
|
||||
needs_priming=0
|
||||
for repo in "${REPOS[@]}"; do
|
||||
url="$(git -C "$repo" remote get-url origin 2>/dev/null)" || continue
|
||||
read -r scheme host <<<"$(remote_authority "$url")"
|
||||
[ -n "${host:-}" ] || continue
|
||||
have_cred "$scheme" "$host" || needs_priming=1
|
||||
done
|
||||
|
||||
# --- fetch token only if needed ------------------------------------------------
|
||||
TOKEN=""
|
||||
if [ "$needs_priming" -eq 1 ] && [ -f "$VAULT" ]; then
|
||||
TOKEN="$(bash "$VAULT" get-field services/gitea.sops.yaml credentials.api.api-token 2>/dev/null | tr -d '\r\n ')"
|
||||
# Fallback for machines missing PyYAML/yq: parse the full decrypted entry.
|
||||
if ! printf '%s' "$TOKEN" | grep -qE '^[0-9a-f]{40}$'; then
|
||||
TOKEN="$(bash "$VAULT" get services/gitea.sops.yaml 2>/dev/null | grep -oE 'api-token:[[:space:]]*[0-9a-f]{40}' | grep -oE '[0-9a-f]{40}' | head -1)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- configure each repo -------------------------------------------------------
|
||||
touch "$CRED_FILE" 2>/dev/null && chmod 600 "$CRED_FILE" 2>/dev/null || true
|
||||
for repo in "${REPOS[@]}"; do
|
||||
url="$(git -C "$repo" remote get-url origin 2>/dev/null)" || continue
|
||||
read -r scheme host <<<"$(remote_authority "$url")"
|
||||
[ -n "${host:-}" ] || continue
|
||||
|
||||
# Prime the store entry if missing and we have a token.
|
||||
if ! have_cred "$scheme" "$host" && [ -n "$TOKEN" ]; then
|
||||
printf '%s://%s:%s@%s\n' "$scheme" "$GIT_USER" "$TOKEN" "$host" >>"$CRED_FILE"
|
||||
fi
|
||||
|
||||
# Only seize the helper away from the prompting GCM (or an unset helper).
|
||||
helper="$(git -C "$repo" config --get credential.helper 2>/dev/null)"
|
||||
case "$helper" in
|
||||
""|*manager*)
|
||||
git -C "$repo" config --local --unset-all credential.helper 2>/dev/null || true
|
||||
git -C "$repo" config --local credential.helper store 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit 0
|
||||
185
.claude/scripts/sync-lock.sh
Normal file
185
.claude/scripts/sync-lock.sh
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/bin/bash
|
||||
# ClaudeTools shared sync-concurrency lock primitive
|
||||
# ----------------------------------------------------------------------------
|
||||
# A per-repo, per-machine critical-section lock shared by every commit path
|
||||
# (sync.sh, /scc, /checkpoint, ...). Extracted VERBATIM from sync.sh so the
|
||||
# logic — which already survived two review rounds — is preserved exactly:
|
||||
# * atomic mkdir lock (flock is frequently absent on Git Bash / MSYS2)
|
||||
# * stale detection (age threshold OR dead owner PID), with a re-verify guard
|
||||
# immediately before clearing so a fresh winner is never stolen from
|
||||
# * rename-aside clear (mv then rm) instead of a bare rm
|
||||
# * exit 75 (EX_TEMPFAIL) on live-lock contention after the wait budget
|
||||
# * sleep 1 busy-spin insurance if clearing persistently fails
|
||||
# * defense-in-depth owner.pid==$$ re-read right after acquisition
|
||||
# * ownership-checked, idempotent release (owner.pid must be ours or empty)
|
||||
#
|
||||
# TWO WAYS TO USE:
|
||||
# 1. SOURCE it (e.g. from sync.sh). Sourcing defines vars + functions ONLY —
|
||||
# no trap is installed and the lock is NOT acquired. The caller sets
|
||||
# SYNC_LOCK_DIR (optional — a default is derived from the current git repo
|
||||
# if unset), installs its own `trap release_sync_lock EXIT INT TERM`, and
|
||||
# calls `acquire_sync_lock` where it wants the critical section to begin.
|
||||
# 2. EXECUTE it as a wrapper: bash sync-lock.sh run <cmd> [args...]
|
||||
# Resolves the lock dir from the current git repo, installs the trap,
|
||||
# acquires the lock, runs <cmd>, then releases via the EXIT trap and exits
|
||||
# with <cmd>'s status. Contention propagates as exit 75.
|
||||
#
|
||||
# Lock-dir basename is fixed at `claudetools-sync.lock` so EVERY tool locking
|
||||
# the same repo root contends on the SAME directory.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Colours — define only if the caller hasn't already (sync.sh defines these
|
||||
# before sourcing; standalone execution needs them too).
|
||||
: "${RED:=\033[0;31m}"
|
||||
: "${GREEN:=\033[0;32m}"
|
||||
: "${YELLOW:=\033[1;33m}"
|
||||
: "${CYAN:=\033[0;36m}"
|
||||
: "${NC:=\033[0m}"
|
||||
|
||||
# Machine label used in lock diagnostics. sync.sh sets MACHINE before sourcing;
|
||||
# guard it so standalone wrapper use (under set -u) never trips on an unset var.
|
||||
: "${MACHINE:=$(hostname 2>/dev/null || echo unknown)}"
|
||||
|
||||
# --- Concurrency lock --------------------------------------------------------
|
||||
# WHY: multiple sync/commit runs on ONE machine must NOT overlap. An interactive
|
||||
# /sync, /scc, or /checkpoint can collide with the scheduled-task sync, or two
|
||||
# concurrent Claude sessions can each stage + commit + fetch + rebase + push and
|
||||
# interleave their git state — corrupting an in-progress rebase, orphaning
|
||||
# commits, or pushing a half-built tree. We serialize the whole critical section
|
||||
# behind a single per-machine lock.
|
||||
#
|
||||
# PORTABILITY: `flock` is frequently ABSENT on Git Bash (MSYS2), so we can't
|
||||
# depend on it. An atomic `mkdir` is the lowest common denominator — it fails if
|
||||
# the directory already exists, atomically, on every platform we run on (Windows
|
||||
# Git Bash, macOS, Linux). The lock lives under .git/ (never tracked, so a blind
|
||||
# `git add -A` can't stage it) and is scoped to this repo.
|
||||
#
|
||||
# Lock dir: default to the current repo's .git/claudetools-sync.lock IF the
|
||||
# caller hasn't already set SYNC_LOCK_DIR (sync.sh sets it explicitly).
|
||||
: "${SYNC_LOCK_DIR:=$(git rev-parse --show-toplevel 2>/dev/null)/.git/claudetools-sync.lock}"
|
||||
SYNC_LOCK_WAIT="${SYNC_LOCK_WAIT:-120}" # max seconds to wait for a held lock before skipping the run
|
||||
SYNC_LOCK_STALE="${SYNC_LOCK_STALE:-600}" # seconds after which a held lock is treated as stale (10 min)
|
||||
SYNC_LOCK_OWNED=0 # becomes 1 only once THIS run owns the lock (gates release)
|
||||
|
||||
# Idempotent release — only removes the lock if THIS process actually owns it
|
||||
# (stored PID == $$), so a "skipping this run" exit can never clobber the lock
|
||||
# held by the live sync we deferred to. Installed as an EXIT trap by the caller
|
||||
# because callers run under `set -e`: the lock must be released on error exits too.
|
||||
release_sync_lock() {
|
||||
if [ "$SYNC_LOCK_OWNED" = "1" ] && [ -d "$SYNC_LOCK_DIR" ]; then
|
||||
local owner_pid
|
||||
owner_pid=$(cat "$SYNC_LOCK_DIR/owner.pid" 2>/dev/null || echo "")
|
||||
if [ -z "$owner_pid" ] || [ "$owner_pid" = "$$" ]; then
|
||||
rm -rf "$SYNC_LOCK_DIR" 2>/dev/null || true
|
||||
fi
|
||||
SYNC_LOCK_OWNED=0
|
||||
fi
|
||||
}
|
||||
|
||||
# Portable liveness check. `kill -0 <pid>` works on Git Bash (it maps to the
|
||||
# Windows process table), macOS, and Linux; guarded so a bad/empty PID is "dead".
|
||||
sync_pid_alive() {
|
||||
local pid="$1"
|
||||
[ -n "$pid" ] || return 1
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
acquire_sync_lock() {
|
||||
local waited=0 owner_pid owner_ts now mtime lock_age stale_aside re_pid re_now re_mtime re_age
|
||||
while true; do
|
||||
if mkdir "$SYNC_LOCK_DIR" 2>/dev/null; then
|
||||
SYNC_LOCK_OWNED=1
|
||||
printf '%s' "$$" > "$SYNC_LOCK_DIR/owner.pid" 2>/dev/null || true
|
||||
# PID + ISO timestamp inside the lock dir, for diagnostics.
|
||||
{
|
||||
printf 'pid=%s\n' "$$"
|
||||
printf 'iso=%s\n' "$(date -u "+%Y-%m-%dT%H:%M:%SZ")"
|
||||
printf 'machine=%s\n' "$MACHINE"
|
||||
} > "$SYNC_LOCK_DIR/owner" 2>/dev/null || true
|
||||
# Defense-in-depth: confirm we still own the dir we just created. If
|
||||
# owner.pid isn't ours, drop ownership and re-evaluate (never fatal
|
||||
# under set -e — comparison is cheap and the body just loops).
|
||||
if [ "$(cat "$SYNC_LOCK_DIR/owner.pid" 2>/dev/null)" != "$$" ]; then
|
||||
SYNC_LOCK_OWNED=0; continue
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# mkdir failed -> the lock is held. Decide whether it's stale or live.
|
||||
owner_pid=$(cat "$SYNC_LOCK_DIR/owner.pid" 2>/dev/null || echo "")
|
||||
owner_ts=$(sed -n 's/^iso=//p' "$SYNC_LOCK_DIR/owner" 2>/dev/null | head -1)
|
||||
[ -n "$owner_ts" ] || owner_ts="unknown"
|
||||
|
||||
# Stale if the dir is older than the threshold OR the owner PID is dead.
|
||||
# `stat -c` is GNU/Git-Bash, `stat -f` is BSD/macOS; fall back to 0.
|
||||
now=$(date +%s 2>/dev/null || echo 0)
|
||||
mtime=$(stat -c %Y "$SYNC_LOCK_DIR" 2>/dev/null || stat -f %m "$SYNC_LOCK_DIR" 2>/dev/null || echo 0)
|
||||
lock_age=$(( now - mtime ))
|
||||
if { [ "$mtime" -gt 0 ] && [ "$lock_age" -ge "$SYNC_LOCK_STALE" ]; } \
|
||||
|| { [ -n "$owner_pid" ] && ! sync_pid_alive "$owner_pid"; }; then
|
||||
# Re-verify staleness IMMEDIATELY before clearing. Between the check
|
||||
# above and here, another racer may have already cleared the stale
|
||||
# lock and acquired a fresh, LIVE one. Re-read owner.pid + mtime NOW;
|
||||
# only rename-aside if it is STILL stale this instant. A freshly
|
||||
# acquired winner has a live PID and fresh mtime, so the loser falls
|
||||
# through to the live-lock wait path instead of stealing the lock.
|
||||
re_pid=$(cat "$SYNC_LOCK_DIR/owner.pid" 2>/dev/null || echo "")
|
||||
re_now=$(date +%s 2>/dev/null || echo 0)
|
||||
re_mtime=$(stat -c %Y "$SYNC_LOCK_DIR" 2>/dev/null || stat -f %m "$SYNC_LOCK_DIR" 2>/dev/null || echo 0)
|
||||
re_age=$(( re_now - re_mtime ))
|
||||
if { [ "$re_mtime" -gt 0 ] && [ "$re_age" -ge "$SYNC_LOCK_STALE" ]; } \
|
||||
|| { [ -n "$re_pid" ] && ! sync_pid_alive "$re_pid"; }; then
|
||||
echo -e "${YELLOW}[WARNING]${NC} removing stale sync lock (held by PID ${re_pid:-?} since ${owner_ts}, age ${re_age}s)"
|
||||
stale_aside="${SYNC_LOCK_DIR}.stale.$$"
|
||||
if mv "$SYNC_LOCK_DIR" "$stale_aside" 2>/dev/null; then
|
||||
rm -rf "$stale_aside" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
sleep 1 # insurance: never tight-spin if clearing persistently fails
|
||||
continue
|
||||
fi
|
||||
|
||||
# Live lock. If we've waited the full budget, skip (a duplicate sync is
|
||||
# harmless to drop — the next scheduled/interactive run catches up).
|
||||
if [ "$waited" -ge "$SYNC_LOCK_WAIT" ]; then
|
||||
echo -e "${YELLOW}[WARNING]${NC} another sync is in progress (held by PID ${owner_pid:-?} since ${owner_ts}); skipping this run"
|
||||
exit 75 # EX_TEMPFAIL: deferred (another sync in progress), not a real success
|
||||
fi
|
||||
sleep 2
|
||||
waited=$(( waited + 2 ))
|
||||
done
|
||||
}
|
||||
# --- end concurrency lock ----------------------------------------------------
|
||||
|
||||
# --- Wrapper mode (direct execution only) ------------------------------------
|
||||
# Sourcing stops here: the block below runs ONLY when this file is executed
|
||||
# directly, never when sourced. So sourcing has zero side effects beyond the
|
||||
# var + function definitions above (no trap, no acquire).
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
# NOT set -e: a non-zero status from the wrapped command must be reported as
|
||||
# this script's own exit code, not swallowed by an errexit abort.
|
||||
set -uo pipefail
|
||||
|
||||
if [ "${1:-}" != "run" ] || [ -z "${2:-}" ]; then
|
||||
echo "usage: $(basename "$0") run <command> [args...]" >&2
|
||||
echo " Acquires the per-repo sync lock, runs <command>, releases, exits with its status." >&2
|
||||
exit 2
|
||||
fi
|
||||
shift # drop the 'run' subcommand; "$@" is now the command + args
|
||||
|
||||
# Resolve the lock dir from the CURRENT repo. Must be inside a git repo.
|
||||
_repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
if [ -z "$_repo_root" ]; then
|
||||
echo -e "${RED}[ERROR]${NC} sync-lock.sh: not inside a git repository (cannot resolve lock dir)" >&2
|
||||
exit 2
|
||||
fi
|
||||
SYNC_LOCK_DIR="$_repo_root/.git/claudetools-sync.lock"
|
||||
|
||||
trap release_sync_lock EXIT INT TERM
|
||||
acquire_sync_lock # exits 75 on contention (propagates to our caller)
|
||||
|
||||
"$@"
|
||||
_status=$?
|
||||
# Release happens via the EXIT trap; mirror the wrapped command's status.
|
||||
exit $_status
|
||||
fi
|
||||
@@ -66,6 +66,32 @@ purge_garbled_paths() {
|
||||
# then vault) before any commit happens.
|
||||
reconcile_git_identity() {
|
||||
local want_name="$1" want_email="$2" cur
|
||||
# Bot-context override: when invoked by the Discord bot, attribute the COMMIT
|
||||
# to the human who requested it (git AUTHOR = mapped requester from users.json)
|
||||
# with "ClaudeTools Bot" as the COMMITTER. Unmapped/unknown requester falls
|
||||
# back to bot-as-author. Strict no-op when CLAUDETOOLS_ACTOR is unset, so
|
||||
# interactive sessions keep identity.json attribution.
|
||||
if [ "${CLAUDETOOLS_ACTOR:-}" = "discord-bot" ]; then
|
||||
local _bot_id
|
||||
_bot_id=$("${PYTHON:-python}" - "$REPO_ROOT/.claude/users.json" "${CLAUDETOOLS_REQUESTER_USER:-}" <<'BOTID'
|
||||
import json, sys
|
||||
usersp, ukey = sys.argv[1], sys.argv[2]
|
||||
name, email = "ClaudeTools Bot", "bot@azcomputerguru.com"
|
||||
if ukey:
|
||||
try:
|
||||
u = json.load(open(usersp))["users"].get(ukey, {})
|
||||
name = u.get("git_name") or u.get("full_name") or name
|
||||
email = u.get("git_email") or u.get("email") or email
|
||||
except Exception:
|
||||
pass
|
||||
print(name + "|" + email)
|
||||
BOTID
|
||||
)
|
||||
want_name="${_bot_id%%|*}"
|
||||
want_email="${_bot_id##*|}"
|
||||
export GIT_COMMITTER_NAME="ClaudeTools Bot"
|
||||
export GIT_COMMITTER_EMAIL="bot@azcomputerguru.com"
|
||||
fi
|
||||
if [ -n "$want_name" ]; then
|
||||
cur=$(git config user.name 2>/dev/null || true)
|
||||
if [ "$cur" != "$want_name" ]; then
|
||||
@@ -91,6 +117,22 @@ else
|
||||
fi
|
||||
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# --- Coord visibility signal (BEST-EFFORT, never blocks/fails the sync) -------
|
||||
# Publishes a per-machine coord component so the fleet can see this machine's
|
||||
# sync state. Pure visibility: every call is guarded so it can NEVER trip the
|
||||
# script's `set -e`, slow the sync beyond a tiny timeout, or change the exit code.
|
||||
COORD_BASE="http://172.16.3.30:8001/api/coord"
|
||||
COORD_SYNC_STARTED=0
|
||||
coord_signal() {
|
||||
local state="${1:-}"
|
||||
curl -s --connect-timeout 2 -m 3 -o /dev/null -X PUT \
|
||||
"$COORD_BASE/components/claudetools/git_sync_${MACHINE}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"state\":\"${state}\",\"version\":\"1.0.0\",\"notes\":\"${state} at ${TIMESTAMP} (${USER_DISPLAY:-?})\",\"updated_by\":\"${MACHINE}/sync\"}" \
|
||||
2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
echo -e "${GREEN}[OK]${NC} Starting ClaudeTools sync from $MACHINE at $TIMESTAMP"
|
||||
|
||||
# Navigate to ClaudeTools directory
|
||||
@@ -121,6 +163,45 @@ cd "$REPO_ROOT"
|
||||
|
||||
echo -e "${GREEN}[OK]${NC} Working directory: $(pwd)"
|
||||
|
||||
# --- Concurrency lock --------------------------------------------------------
|
||||
# WHY: multiple sync runs on ONE machine must NOT overlap. An interactive /sync
|
||||
# or /save can collide with the scheduled-task sync, or two concurrent Claude
|
||||
# sessions can each stage + commit + fetch + rebase + push and interleave their
|
||||
# git state — corrupting an in-progress rebase, orphaning commits, or pushing a
|
||||
# half-built tree. We serialize the whole claudetools critical section (Phase 1a
|
||||
# submodule update, staging, commit, fetch, rebase, push — and by extension the
|
||||
# vault phase) behind a single per-machine lock.
|
||||
#
|
||||
# The lock primitive (mkdir-atomic lock, stale detection, ownership-checked
|
||||
# release, exit-75-on-contention) lives in the SHAREABLE library sync-lock.sh so
|
||||
# other commit paths (/scc, /checkpoint) can contend on the SAME lock dir. We
|
||||
# set SYNC_LOCK_DIR explicitly, source the library (which defines the vars +
|
||||
# functions but installs NO trap and acquires NOTHING on source), then install
|
||||
# our own EXIT trap and acquire — exactly as before. We are already cd'd into
|
||||
# REPO_ROOT, and the path is absolute, so the source resolves from any CWD.
|
||||
SYNC_LOCK_DIR="$REPO_ROOT/.git/claudetools-sync.lock"
|
||||
# shellcheck source=./sync-lock.sh
|
||||
source "$REPO_ROOT/.claude/scripts/sync-lock.sh"
|
||||
|
||||
# Finalize: best-effort coord signal (only if we actually started a sync), then
|
||||
# ALWAYS release the lock (idempotent + ownership-gated). $? is captured FIRST so
|
||||
# the coord branch reflects the real script outcome. This trap must NOT call
|
||||
# `exit` — letting it return preserves the script's true exit code.
|
||||
sync_finalize() {
|
||||
local rc=$?
|
||||
if [ "$COORD_SYNC_STARTED" = "1" ]; then
|
||||
if [ "$rc" = "0" ]; then coord_signal idle; else coord_signal degraded; fi
|
||||
fi
|
||||
release_sync_lock
|
||||
return "$rc" # preserve the script's true exit code regardless of release_sync_lock's status
|
||||
}
|
||||
trap sync_finalize EXIT INT TERM
|
||||
acquire_sync_lock
|
||||
echo -e "${GREEN}[OK]${NC} Acquired sync lock ($SYNC_LOCK_DIR)"
|
||||
COORD_SYNC_STARTED=1 # set BEFORE the signal so a crash in the gap still finalizes (degraded)
|
||||
coord_signal syncing
|
||||
# --- end concurrency lock ----------------------------------------------------
|
||||
|
||||
# Detect Python interpreter — read from identity.json first, fall back to detection
|
||||
PYTHON=""
|
||||
if [ -f ".claude/identity.json" ] && command -v jq >/dev/null 2>&1; then
|
||||
@@ -268,6 +349,18 @@ if [ -n "$(git status --porcelain)" ]; then
|
||||
purge_garbled_paths
|
||||
git add -A
|
||||
|
||||
# Submodule-safe staging (Task 1): `git add -A` stages submodule gitlink (pointer)
|
||||
# changes. The parent's pinned commit intentionally lags the submodule's main, so
|
||||
# auto-committing the pointer bumps a possibly-stale gitlink. Unstage every submodule
|
||||
# gitlink unless the operator opted in with --with-submodules. This eliminates the
|
||||
# manual "detach submodule to its pin before /save" dance.
|
||||
if [ "${ADVANCE_SUBMODULES:-0}" != "1" ] && [ -f ".gitmodules" ]; then
|
||||
while IFS= read -r sm_path; do
|
||||
[ -n "$sm_path" ] || continue
|
||||
git reset -q HEAD -- "$sm_path" 2>/dev/null || true
|
||||
done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' | awk '{print $2}')
|
||||
fi
|
||||
|
||||
# Commit message (Co-Authored-By uses local git user if configured)
|
||||
COMMIT_MSG="sync: auto-sync from $MACHINE at $TIMESTAMP
|
||||
|
||||
@@ -276,10 +369,19 @@ Machine: $MACHINE
|
||||
Timestamp: $TIMESTAMP"
|
||||
|
||||
if git diff-index --quiet --cached HEAD -- 2>/dev/null; then
|
||||
echo -e "${GREEN}[OK]${NC} No stageable changes (submodule internal changes skipped)."
|
||||
echo -e "${GREEN}[OK]${NC} No stageable changes (submodule pointer + internal changes skipped)."
|
||||
else
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo -e "${GREEN}[OK]${NC} Committed."
|
||||
# Harness guard (Task 4): WARN-ONLY during rollout — logs footguns (conflict
|
||||
# markers, unencrypted sops, private-key material) to .claude/harness/guard.log
|
||||
# but does NOT block unless HARNESS_GUARD_FATAL=1. SKIP_HARNESS_GUARD=1 bypasses.
|
||||
GUARD_RC=0
|
||||
bash .claude/scripts/harness-guard.sh || GUARD_RC=$?
|
||||
if [ "$GUARD_RC" != "0" ]; then
|
||||
echo -e "${YELLOW}[WARNING]${NC} harness-guard blocked the commit (HARNESS_GUARD_FATAL set). Staged changes left in place; set SKIP_HARNESS_GUARD=1 to override."
|
||||
else
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo -e "${GREEN}[OK]${NC} Committed."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}[OK]${NC} No local changes to commit."
|
||||
@@ -465,6 +567,38 @@ else
|
||||
echo -e "${GREEN}[OK]${NC} Global commands already current."
|
||||
fi
|
||||
|
||||
# Phase 5c: Apply config — sync skills to the global Claude dir.
|
||||
# Skills are directories (SKILL.md + scripts/refs); the global ~/.claude/skills/ is
|
||||
# where the CLI loads invocable skills from. A machine that lost its global skills
|
||||
# (e.g. wiped) self-heals here. One-way (repo -> global), idempotent, soft-fails.
|
||||
echo ""
|
||||
echo "=== Phase 5c: Apply config (skills -> global) ==="
|
||||
GLOBAL_SKILL_DIR="$HOME/.claude/skills"
|
||||
set +e
|
||||
mkdir -p "$GLOBAL_SKILL_DIR"
|
||||
SKILL_UPDATED=0
|
||||
SKILL_NAMES=""
|
||||
if [ -d ".claude/skills" ]; then
|
||||
for d in .claude/skills/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
name=$(basename "$d")
|
||||
dst="$GLOBAL_SKILL_DIR/$name"
|
||||
if [ ! -d "$dst" ] || ! diff -rq ".claude/skills/$name" "$dst" >/dev/null 2>&1; then
|
||||
rm -rf "$dst"
|
||||
if cp -rf ".claude/skills/$name" "$GLOBAL_SKILL_DIR/"; then
|
||||
SKILL_UPDATED=$((SKILL_UPDATED + 1))
|
||||
SKILL_NAMES="$SKILL_NAMES $name"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
set -e
|
||||
if [ "$SKILL_UPDATED" -gt 0 ]; then
|
||||
echo -e "${GREEN}[OK]${NC} Skills synced to global: $SKILL_UPDATED updated —$SKILL_NAMES"
|
||||
else
|
||||
echo -e "${GREEN}[OK]${NC} Global skills already current."
|
||||
fi
|
||||
|
||||
# Phase 6: Vault sync
|
||||
echo ""
|
||||
echo "=== Phase 6: Vault sync ==="
|
||||
|
||||
174
.claude/scripts/test-harness-guard.sh
Normal file
174
.claude/scripts/test-harness-guard.sh
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-harness-guard.sh — false-positive / true-positive test matrix for harness-guard.sh.
|
||||
#
|
||||
# WHY: the guard is WARN-ONLY today; before it is promoted to FATAL (blocking) the
|
||||
# harness-optimization plan requires proof of ZERO false positives on legitimate content
|
||||
# plus reliable detection of the real footguns. This script is that proof, repeatable.
|
||||
#
|
||||
# It spins up a throwaway git repo, stages synthetic files, runs the REAL harness-guard.sh
|
||||
# inside it (the guard cd's to its repo root and inspects the staged blobs), and asserts
|
||||
# WARN / no-WARN per case. It also scans the actual tracked tree for content that the
|
||||
# guard's detection patterns would flag, to size the real-world false-positive blast radius.
|
||||
#
|
||||
# Read-only against the real repo (the synthetic staging happens in a temp repo under TMP).
|
||||
# Exit 0 = all cases passed; exit 1 = at least one mismatch (promotion NOT yet safe).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "[ERROR] not in a git repo"; exit 2; }
|
||||
GUARD="$REPO_ROOT/.claude/scripts/harness-guard.sh"
|
||||
[ -f "$GUARD" ] || { echo "[ERROR] guard not found: $GUARD"; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}/guardtest.$$")"
|
||||
mkdir -p "$TMP"
|
||||
cleanup() { rm -rf "$TMP" 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- isolated temp repo so we can stage synthetic content without touching the real tree
|
||||
git -C "$TMP" init -q
|
||||
git -C "$TMP" config user.name "guard-test"
|
||||
git -C "$TMP" config user.email "guard-test@local"
|
||||
mkdir -p "$TMP/.claude/harness" # so the guard's log path mkdir is a no-op
|
||||
|
||||
PASS=0; FAIL=0
|
||||
FAILED_CASES=""
|
||||
|
||||
# run_case <name> <expect: warn|clean> <file> <heredoc-content-on-stdin>
|
||||
run_case() {
|
||||
local name="$1" expect="$2" file="$3" out rc warned
|
||||
# reset the temp index/worktree
|
||||
git -C "$TMP" reset -q --hard >/dev/null 2>&1 || true
|
||||
git -C "$TMP" rm -rq --cached . >/dev/null 2>&1 || true
|
||||
rm -f "$TMP"/*.* "$TMP"/* 2>/dev/null || true
|
||||
mkdir -p "$TMP/$(dirname "$file")" 2>/dev/null || true
|
||||
cat > "$TMP/$file"
|
||||
git -C "$TMP" add -A >/dev/null 2>&1
|
||||
# run the REAL guard from inside the temp repo
|
||||
out="$( cd "$TMP" && bash "$GUARD" 2>&1 )"; rc=$?
|
||||
if printf '%s\n' "$out" | grep -q '\[harness-guard\]\[WARN\]'; then warned=1; else warned=0; fi
|
||||
|
||||
local got; [ "$warned" = 1 ] && got="warn" || got="clean"
|
||||
if [ "$got" = "$expect" ]; then
|
||||
PASS=$((PASS+1)); printf ' [PASS] %-34s expected=%-5s got=%-5s\n' "$name" "$expect" "$got"
|
||||
else
|
||||
FAIL=$((FAIL+1)); FAILED_CASES="$FAILED_CASES $name"
|
||||
printf ' [FAIL] %-34s expected=%-5s got=%-5s\n' "$name" "$expect" "$got"
|
||||
printf ' guard said: %s\n' "$(printf '%s' "$out" | grep WARN | head -2 | tr '\n' '|')"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "============================================================"
|
||||
echo " harness-guard false-positive / true-positive matrix"
|
||||
echo " guard: $GUARD"
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
echo "TRUE POSITIVES (must WARN):"
|
||||
|
||||
run_case "real-conflict-hunk" warn "src/app.rs" <<'EOF'
|
||||
fn main() {
|
||||
<<<<<<< HEAD
|
||||
let x = 1;
|
||||
=======
|
||||
let x = 2;
|
||||
>>>>>>> feature
|
||||
}
|
||||
EOF
|
||||
|
||||
run_case "unencrypted-sops" warn "infra/secret.sops.yaml" <<'EOF'
|
||||
api_key: super-secret-plaintext
|
||||
password: hunter2
|
||||
EOF
|
||||
|
||||
run_case "private-key-openssh" warn "keys/id_ed25519" <<'EOF'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAAB
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
EOF
|
||||
|
||||
run_case "private-key-rsa" warn "keys/id_rsa" <<'EOF'
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEA...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "FALSE-POSITIVE VECTORS (must stay CLEAN):"
|
||||
|
||||
# markdown setext H1 underline (long run) — must stay clean
|
||||
run_case "markdown-setext-underline-long" clean "docs/title.md" <<'EOF'
|
||||
My Document Title
|
||||
=================
|
||||
|
||||
Body text here.
|
||||
EOF
|
||||
|
||||
# the precise edge: a setext underline that is EXACTLY seven equals (git's conflict-middle
|
||||
# marker). The old standalone '=======$' rule false-positived here; the pair-required rule
|
||||
# must keep it clean (no open/close markers present).
|
||||
run_case "setext-underline-exactly-7" clean "docs/short.md" <<'EOF'
|
||||
Title X
|
||||
=======
|
||||
|
||||
body
|
||||
EOF
|
||||
|
||||
# a horizontal divider of exactly seven equals in a comment — must stay clean
|
||||
run_case "divider-exactly-7-equals" clean "notes/changelog.md" <<'EOF'
|
||||
## Release notes
|
||||
=======
|
||||
- item one
|
||||
EOF
|
||||
|
||||
# a doc that *mentions* a single conflict marker (a git tutorial) — no real hunk
|
||||
run_case "doc-mentions-open-marker" clean "docs/git-tutorial.md" <<'EOF'
|
||||
When git hits a conflict it inserts a line starting with `<<<<<<< HEAD`.
|
||||
You then edit the file to resolve it. (No closing marker in this doc.)
|
||||
EOF
|
||||
|
||||
# already-encrypted sops file — has ENC[ / sops: markers, must NOT warn
|
||||
run_case "encrypted-sops" clean "infra/real.sops.yaml" <<'EOF'
|
||||
api_key: ENC[AES256_GCM,data:abc==,iv:xyz==,tag:q==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
age:
|
||||
- recipient: age1xyz
|
||||
EOF
|
||||
|
||||
# public key — guard targets PRIVATE keys only; a public key must not warn
|
||||
run_case "public-key-ssh" clean "keys/id_ed25519.pub" <<'EOF'
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc123 user@host
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE
|
||||
-----END PUBLIC KEY-----
|
||||
EOF
|
||||
|
||||
# a .sops.yaml.example template (not a real vault file path) with placeholder text
|
||||
run_case "sops-example-template" clean "infra/secret.sops.yaml.example" <<'EOF'
|
||||
api_key: <your-key-here>
|
||||
note: copy to secret.sops.yaml and encrypt with sops
|
||||
EOF
|
||||
|
||||
# normal source with '=======' inside a comment banner (not its own 7-char line)
|
||||
run_case "comment-banner-equals" clean "src/lib.rs" <<'EOF'
|
||||
// ======= section: helpers =======
|
||||
fn helper() {}
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "REAL-CORPUS BLAST RADIUS:"
|
||||
# Old standalone rule surface (for context): exactly-7-equals lines that USED to false-positive.
|
||||
OLD_EQ="$(git -C "$REPO_ROOT" grep -lE '^=======$' 2>/dev/null | wc -l | tr -d '[:space:]')"
|
||||
# New rule surface: files with BOTH an open and a close marker = a real conflict (should be 0).
|
||||
OPEN_HITS="$(git -C "$REPO_ROOT" grep -lE '^<<<<<<< ' 2>/dev/null | sort)"
|
||||
CLOSE_HITS="$(git -C "$REPO_ROOT" grep -lE '^>>>>>>> ' 2>/dev/null | sort)"
|
||||
BOTH="$(comm -12 <(printf '%s\n' "$OPEN_HITS") <(printf '%s\n' "$CLOSE_HITS") | grep -c . )"
|
||||
echo " tracked files with a lone '^=======\$' line (OLD rule false-positive surface): $OLD_EQ"
|
||||
echo " tracked files with BOTH open+close markers (NEW rule = real conflicts): $BOTH"
|
||||
echo " -> NEW rule flags only genuine conflict hunks; lone dividers/underlines are clean."
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " RESULT: PASS $PASS FAIL $FAIL"
|
||||
[ -n "$FAILED_CASES" ] && echo " failed:$FAILED_CASES"
|
||||
echo "============================================================"
|
||||
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
||||
@@ -30,6 +30,34 @@ if [ -z "$PYTHON" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Bot-context override: the Discord bot sets CLAUDETOOLS_ACTOR=discord-bot plus
|
||||
# the requester it is acting for (CLAUDETOOLS_REQUESTER / _USER, per session).
|
||||
# Attribute the log to the BOT as executor and the human requester as originator.
|
||||
# Strict no-op when the env is unset — interactive sessions are unaffected.
|
||||
if [ "${CLAUDETOOLS_ACTOR:-}" = "discord-bot" ]; then
|
||||
"$PYTHON" - "$ID" "$USERS" <<'BOTEOF'
|
||||
import json, os, sys
|
||||
idp, usersp = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
machine = json.load(open(idp)).get("machine", "unknown")
|
||||
except Exception:
|
||||
machine = "unknown"
|
||||
requester = os.environ.get("CLAUDETOOLS_REQUESTER", "an unrecognized Discord user")
|
||||
ukey = os.environ.get("CLAUDETOOLS_REQUESTER_USER", "")
|
||||
role = ""
|
||||
if ukey:
|
||||
try:
|
||||
role = json.load(open(usersp))["users"].get(ukey, {}).get("role", "")
|
||||
except Exception:
|
||||
pass
|
||||
print("## User")
|
||||
print(f"- **Executed by:** ClaudeTools Discord Bot ({machine})")
|
||||
print(f"- **Requested by:** {requester}" + (f" - {role}" if role else ""))
|
||||
print("- **Role:** automation (acting on the requester's behalf)")
|
||||
BOTEOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"$PYTHON" - "$ID" "$USERS" <<'PYEOF'
|
||||
import json, sys, socket, re
|
||||
idp, usersp = sys.argv[1], sys.argv[2]
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"permissions": {
|
||||
"defaultMode": "bypassPermissions"
|
||||
},
|
||||
"env": {
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GCM_INTERACTIVE": "Never"
|
||||
},
|
||||
"preferences": {
|
||||
"autoCompact": true,
|
||||
"verbose": false
|
||||
@@ -37,6 +41,11 @@
|
||||
"type": "command",
|
||||
"command": "bash -c 'if [ -f \"${CLAUDE_PROJECT_DIR}/.claude/scripts/sync-memory.sh\" ]; then nohup bash \"${CLAUDE_PROJECT_DIR}/.claude/scripts/sync-memory.sh\" >/dev/null 2>&1 & fi; exit 0'",
|
||||
"timeout": 10
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'if [ -f \"${CLAUDE_PROJECT_DIR}/.claude/scripts/setup-git-auth.sh\" ]; then nohup bash \"${CLAUDE_PROJECT_DIR}/.claude/scripts/setup-git-auth.sh\" >/dev/null 2>&1 & fi; exit 0'",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
155
.claude/skills/agy/SKILL.md
Normal file
155
.claude/skills/agy/SKILL.md
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
name: agy
|
||||
description: >
|
||||
Route a task to the official Google Gemini CLI for an independent second
|
||||
model — a sibling of the `grok` second-opinion router. Use for: an
|
||||
independent, different-vendor SECOND OPINION or adversarial VERIFICATION of a
|
||||
Claude finding/design before acting on it, a Gemini code REVIEW of a file /
|
||||
set of files / git diff, and one-shot Gemini TEXT answers. Invoke on:
|
||||
"ask gemini", "gemini verify", "second opinion from gemini", "gemini review",
|
||||
"agy ...". Gemini is an independent second model (and Google-ecosystem reach),
|
||||
NOT a replacement for Claude's own codebase work.
|
||||
---
|
||||
|
||||
# AGY — Gemini capability router
|
||||
|
||||
Claude shells out to the locally-installed **Google Gemini CLI** (`gemini`, npm
|
||||
global, v0.45.1) for a genuinely independent, different-vendor second model.
|
||||
AGY is the sibling of [`grok`](../grok/SKILL.md): both are second-opinion /
|
||||
review routers. Use whichever you want a second model from (or both, to triangulate).
|
||||
Verified working on this machine (2026-06-05): text, verify, review (single
|
||||
file / file set / git diff), image-analyze (vision input), search (live Google
|
||||
web search). All KEYLESS — they work on Google OAuth, no API key.
|
||||
|
||||
**Auth:** Gemini uses **Google login (OAuth)** — **no API key**. Creds live at
|
||||
`~/.gemini/oauth_creds.json`. If calls fail with an auth error, run `gemini`
|
||||
interactively once and choose **"Login with Google"**, then retry.
|
||||
|
||||
## The wrapper
|
||||
|
||||
```
|
||||
bash "$CLAUDETOOLS_ROOT/.claude/skills/agy/scripts/ask-gemini.sh" <mode> ...
|
||||
```
|
||||
|
||||
| Mode | Usage | What it does |
|
||||
|------|-------|--------------|
|
||||
| `text` | `ask-gemini.sh text "<prompt>"` or `text --prompt-file <path>` | One-shot text answer from an independent model. `--prompt-file` for long content (review/summarize a doc). Default model routing. |
|
||||
| `verify` | `ask-gemini.sh verify "<claim/finding>"` or `verify --prompt-file <path>` | Adversarial second opinion — Gemini tries to REFUTE / find gaps, returns a verdict + reasons. Pinned to the strong model. |
|
||||
| `review` | `ask-gemini.sh review <file-path> ["<instructions>"]` | Gemini reads the file itself (its `read_file` tool, read-only `plan` mode) and reviews it. Path resolution: absolute, CWD-relative, or relative to `$CLAUDETOOLS_ROOT` — **see the path gotcha below**. Spaces OK. Works even on gitignored files. |
|
||||
| `review-files` | `ask-gemini.sh review-files [-i "<instr>"] <f1> [f2 …]` | Review a **set** of files together (cross-file consistency, multi-file change). Same path resolution as `review` (**see gotcha below**); spaces OK. No code passed as a shell arg. |
|
||||
| `review-diff` | `ask-gemini.sh review-diff [-C <repo-dir>] [-i "<instr>"] <gitref> [-- <pathspec>]` | Review a **git diff** (`git diff <gitref>` from `<repo-dir>`; default repo root, use `-C` for a submodule e.g. `-C projects/msp-tools/guru-rmm`). Diff goes via the prompt file; Gemini can `read_file` changed files for full context. |
|
||||
| `image-analyze` | `ask-gemini.sh image-analyze <image-path> ["<question>"]` | **Vision** — Gemini `read_file`s the image and describes/answers about it. Pins the **pro vision model** (the default flash-lite router hallucinates image content). Path absolute or repo-relative; spaces OK. KEYLESS (works on OAuth). |
|
||||
| `search` | `ask-gemini.sh search "<query>"` (or `search --prompt-file <path>`) | **Live Google web search** (sibling of `grok xsearch`) — Gemini uses its `google_web_search` tool and returns the answer **with source URLs**. KEYLESS (works on OAuth). |
|
||||
| `raw` | `ask-gemini.sh raw <gemini args...>` | Escape hatch — passes args straight to `gemini`. |
|
||||
|
||||
The script runs Gemini headless with `-o json`, extracts the answer from
|
||||
`.response` (parsing from the first `{` so the CLI's cosmetic warning lines are
|
||||
ignored), and keeps stderr separate from the JSON so 429-backoff / warning noise
|
||||
never corrupts the parse.
|
||||
|
||||
> [!WARNING]
|
||||
> **Path gotcha for `review` / `review-files` (this has bitten us repeatedly).**
|
||||
> A relative path is resolved against ONLY two roots: your **current directory**,
|
||||
> and **`$CLAUDETOOLS_ROOT`** (`/d/claudetools`). It is NOT resolved against a
|
||||
> submodule or any arbitrary subdir. So a path like `server/src/api/auth.rs` that
|
||||
> is relative to a submodule (e.g. `projects/msp-tools/guru-connect/`) fails with
|
||||
> `file not found` whenever your CWD isn't that submodule — even though the file
|
||||
> obviously exists. **When reviewing files in a submodule or any non-root subtree,
|
||||
> pass ABSOLUTE paths** (e.g. build the list with `find "$(pwd)/server/src" -name '*.rs'`
|
||||
> from inside the submodule). Absolute paths always work regardless of CWD and
|
||||
> tolerate spaces. (For `review-diff`, the analogous fix is `-C <submodule-dir>`.)
|
||||
|
||||
### Model
|
||||
|
||||
- `text` uses Gemini's **default routing** (currently a flash-tier model) — fast, cheap.
|
||||
- `verify` / `review*` pin a **strong** model — `gemini-3.1-pro-preview` (verified
|
||||
available on this account 2026-06-05; the CLI's own pro tier).
|
||||
- Override either with `GEMINI_MODEL=<id>` (e.g. `GEMINI_MODEL=gemini-2.5-pro`).
|
||||
- `image-analyze` and `search` also pin the strong model (`GEMINI_MODEL` still honored).
|
||||
|
||||
### Multimodal: image INPUT works, image GENERATION does not
|
||||
|
||||
- **Image INPUT (vision) works on OAuth** — `image-analyze` reads an image with the
|
||||
pinned **pro vision model** and describes it correctly. The default flash-lite
|
||||
router HALLUCINATES image content, which is why the pro model is pinned.
|
||||
- **Image GENERATION (nano-banana) does NOT work on OAuth** — it needs a Google AI
|
||||
Studio `NANOBANANA_API_KEY` plus the `nanobanana` extension. **Deferred** for now.
|
||||
Image/video **generation** stays [GROK](../grok/SKILL.md)'s lane (`grok image` /
|
||||
`grok video`); AGY's multimodal support is read/analyze only.
|
||||
|
||||
## Machine availability (fleet)
|
||||
|
||||
AGY is **per-machine** — the skill syncs fleet-wide but the `gemini` binary does
|
||||
not. Availability is gated by `identity.json` (per-machine, gitignored):
|
||||
|
||||
```json
|
||||
"gemini": { "installed": true,
|
||||
"binary": "C:/Users/guru/AppData/Roaming/npm/gemini",
|
||||
"auth": "oauth", "is_fleet_host": true,
|
||||
"capabilities": ["text","verify","review","image-analyze","search"] }
|
||||
```
|
||||
|
||||
- If `gemini.installed` is `false` (or the block is absent), `ask-gemini.sh` exits
|
||||
**3** with routing guidance instead of failing obscurely. Claude on such a
|
||||
machine should NOT attempt local Gemini.
|
||||
- **Fleet Gemini hosts: `GURU-5070`, `GURU-BEAST-ROG`** — machines with the Gemini
|
||||
CLI installed and Google-OAuth'd. When others get it, install
|
||||
`@google/gemini-cli`, run `gemini` once to log in with Google, then set their
|
||||
`identity.json` `gemini` block (and update this line).
|
||||
|
||||
**Remote routing (NOT yet wired):** a non-host machine cannot run Gemini locally.
|
||||
To fulfill an AGY request from elsewhere, route it to the host (`GURU-5070`) —
|
||||
same pending channels as Grok (GuruRMM agent exec, a relay, or a coord-API job
|
||||
queue). Until that's built, AGY requests originate on the host machine.
|
||||
|
||||
## When to route to Gemini (AGY)
|
||||
|
||||
- **Independent verification** — a genuinely different vendor/model to red-team a
|
||||
Claude finding or design before acting on it. (`verify`)
|
||||
- **Second-model code review** — have Gemini read and critique a file, a set of
|
||||
files, or a diff independently of Claude. (`review`, `review-files`, `review-diff`)
|
||||
- **Diverse drafts / second opinion** — alternative phrasing or approach to
|
||||
compare. (`text`)
|
||||
- **Google-ecosystem reach** — when a Google-side model/behavior is specifically
|
||||
wanted as the comparison point.
|
||||
|
||||
AGY and [GROK](../grok/SKILL.md) are sibling second-opinion routers. Pick one, or
|
||||
run both and compare — disagreement between them is a strong signal to slow down.
|
||||
|
||||
## When NOT to
|
||||
|
||||
- Pure classify / extract / summarize → cheaper via Tier-0 Ollama (`.claude/OLLAMA.md`).
|
||||
- Editing this repo's code → Claude's own agents own the codebase work. Gemini's
|
||||
`review*` modes are read-only (`--approval-mode plan`) by design; do not give
|
||||
Gemini write access to this repo.
|
||||
- Image / video **generation** → that's GROK's lane (`grok image` / `grok video`),
|
||||
not Gemini here (nano-banana needs an API key — deferred). Gemini CAN analyze an
|
||||
image you give it (`image-analyze`, vision input on OAuth).
|
||||
- **Never** delegate unsupervised destructive / production actions to Gemini.
|
||||
Always review Gemini output before acting on it — like Grok, it can over-claim.
|
||||
|
||||
## Safety / operational notes
|
||||
|
||||
- `--skip-trust` is REQUIRED for headless runs (the CWD isn't a Gemini "trusted
|
||||
folder"). Equivalent env: `GEMINI_CLI_TRUST_WORKSPACE=true`. The wrapper passes it.
|
||||
- `review*` runs under `--approval-mode plan` (read-only): Gemini can read files
|
||||
but cannot modify anything. Do not change this to `auto_edit`/`yolo`.
|
||||
- Gemini's `read_file` honors `.gitignore` **and** a workspace sandbox (only files
|
||||
inside the workspace are readable). The wrapper sidesteps both by copying each
|
||||
review target into a temp dir added via `--include-directories` — so review
|
||||
works for tracked, gitignored, and spaced-path files alike.
|
||||
- Prompts are passed via `-p "$(cat <prompt-file>)"` built from a temp file, not
|
||||
inline shell args (avoids quote hell with long/structured content).
|
||||
- stdin is always closed (`</dev/null`) so `-p` never hangs waiting on stdin.
|
||||
- stdout carries two cosmetic warning lines ("True color (24-bit) support not
|
||||
detected", "Ripgrep is not available...") before output; JSON extraction from
|
||||
the first `{` ignores them. A transient `429 No capacity` backoff may appear on
|
||||
**stderr** and self-recovers — it does not affect the parsed answer.
|
||||
|
||||
## Reference
|
||||
- Binary: npm global `gemini` (`C:/Users/guru/AppData/Roaming/npm/gemini` on the
|
||||
host; the npm global dir is on PATH). The wrapper auto-locates it or honors `GEMINI=`.
|
||||
- Version 0.45.1. Auth: Google OAuth (`~/.gemini/oauth_creds.json`), no API key.
|
||||
- Headless contract: `gemini -p "<prompt>" -o json --skip-trust </dev/null` →
|
||||
`{session_id, response, stats}`; answer is `.response`.
|
||||
- Sibling router: [`grok`](../grok/SKILL.md) (image/video/live-data + second opinion).
|
||||
366
.claude/skills/agy/scripts/ask-gemini.sh
Normal file
366
.claude/skills/agy/scripts/ask-gemini.sh
Normal file
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
# ask-gemini.sh — Claude -> Google Gemini CLI router (independent second model).
|
||||
#
|
||||
# Sibling of ask-grok.sh. Routes a task to the official Google Gemini CLI
|
||||
# (`gemini`, npm global) for an independent, different-vendor second opinion,
|
||||
# verification, or a Gemini code review. Headless, safe-by-default, JSON-parsed.
|
||||
#
|
||||
# Auth is Google login (OAuth) — NO API key. Creds: ~/.gemini/oauth_creds.json.
|
||||
# If a call fails with an auth error, run `gemini` interactively once and pick
|
||||
# "Login with Google".
|
||||
#
|
||||
# Output contract (VERIFIED on GURU-5070, gemini 0.45.1):
|
||||
# - Prefer JSON: `gemini -p ... -o json` -> {session_id, response, stats}.
|
||||
# The answer text is `.response`. stdout may carry two cosmetic warning lines
|
||||
# ("True color..." / "Ripgrep is not available...") before the JSON; we extract
|
||||
# the object starting at the FIRST '{' to ignore them. stderr (429 backoff,
|
||||
# warnings) is captured SEPARATELY and never fed to the JSON parser.
|
||||
# - `--skip-trust` is REQUIRED headless (the CWD isn't a trusted folder).
|
||||
# - stdin is always closed (</dev/null) so `-p` never hangs waiting on stdin.
|
||||
#
|
||||
# File reads (review*): Gemini's read_file honors .gitignore AND a workspace
|
||||
# sandbox (only files under the workspace/included dirs are readable). To make
|
||||
# review robust for ANY file (tracked, gitignored, with spaces), we copy each
|
||||
# target into a temp dir and add it to the workspace via --include-directories.
|
||||
# review-diff runs with the repo dir included so changed files read in place.
|
||||
#
|
||||
# Usage:
|
||||
# ask-gemini.sh text "<prompt>" # one-shot answer
|
||||
# ask-gemini.sh text --prompt-file <path> # long content
|
||||
# ask-gemini.sh verify "<claim or finding to refute>" # adversarial check
|
||||
# ask-gemini.sh verify --prompt-file <path>
|
||||
# ask-gemini.sh review <file> [instructions] # gemini reads + reviews one file
|
||||
# ask-gemini.sh review-files [-i "instr"] <f1> [f2 ...] # review a SET of files together
|
||||
# ask-gemini.sh review-diff [-C <repo-dir>] [-i "instr"] <gitref> [-- <pathspec>]
|
||||
# ask-gemini.sh image-analyze <image-path> ["question"] # vision: read_file image + describe (PRO model)
|
||||
# ask-gemini.sh search "<query>" # Google-grounded live web search + sources
|
||||
# ask-gemini.sh raw <gemini args...> # escape hatch
|
||||
#
|
||||
# Exit: 0 ok, 1 no result, 2 usage, 3 not installed here, 127 gemini/python not found.
|
||||
set -uo pipefail
|
||||
SELF="ask-gemini"
|
||||
|
||||
PY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)"
|
||||
[ -z "$PY" ] && { echo "[$SELF] python (py/python/python3) required for JSON parsing" >&2; exit 127; }
|
||||
|
||||
# --- path conversion: native-Windows path for the gemini args (no-op off Windows) ---
|
||||
# gemini is a native Windows binary (npm shim -> node.exe); Git Bash hands it POSIX
|
||||
# paths (/tmp, /c/.., /d/..) it cannot resolve. cygpath -w converts to C:\... on
|
||||
# MSYS/Cygwin; on Linux/macOS it passes through unchanged. Explicit conversion
|
||||
# removes reliance on MSYS auto-conversion (which breaks on spaces/edge cases).
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
winpath() { cygpath -w -- "$1" 2>/dev/null || printf '%s' "$1"; }
|
||||
else
|
||||
winpath() { printf '%s' "$1"; }
|
||||
fi
|
||||
|
||||
# --- identity.json (per-machine, gitignored) declares whether gemini is installed here ---
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
||||
IDFILE=""
|
||||
[ -n "${CLAUDETOOLS_ROOT:-}" ] && [ -f "$CLAUDETOOLS_ROOT/.claude/identity.json" ] && IDFILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
|
||||
[ -z "$IDFILE" ] && IDFILE="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd)/identity.json"
|
||||
idgem() { # read field $1 from identity.json .gemini (empty if absent)
|
||||
[ -f "$IDFILE" ] || { echo ""; return; }
|
||||
"$PY" -c "import json,sys
|
||||
try:
|
||||
g=(json.load(sys.stdin).get('gemini') or {}); v=g.get('$1','')
|
||||
print('' if v is None else (str(v).lower() if isinstance(v,bool) else v))
|
||||
except Exception: print('')" < "$IDFILE"
|
||||
}
|
||||
|
||||
# If identity explicitly says gemini is NOT installed here, fail fast with guidance.
|
||||
if [ "$(idgem installed)" = "false" ]; then
|
||||
echo "[$SELF] gemini is not installed on this machine (identity.json gemini.installed=false)." >&2
|
||||
echo "[$SELF] Gemini runs only on the fleet host. Route this request there, or install the gemini CLI (npm i -g @google/gemini-cli) + set identity.json gemini.installed=true." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- locate the gemini binary: GEMINI env > identity.json gemini.binary > auto-locate ---
|
||||
# An explicit GEMINI= override that isn't runnable is a user error -> fail clearly up front
|
||||
# (covers absolute paths AND a bare name resolvable on PATH, e.g. GEMINI=gemini).
|
||||
GEMINI="${GEMINI:-}"
|
||||
if [ -n "$GEMINI" ] && [ ! -x "$GEMINI" ] && ! command -v "$GEMINI" >/dev/null 2>&1; then
|
||||
echo "[$SELF] GEMINI='$GEMINI' is not an executable gemini binary." >&2; exit 127
|
||||
fi
|
||||
cand="$(idgem binary)"
|
||||
[ -z "$GEMINI" ] && [ -n "$cand" ] && [ -x "$cand" ] && GEMINI="$cand"
|
||||
if [ -z "$GEMINI" ]; then
|
||||
if command -v gemini >/dev/null 2>&1; then GEMINI="$(command -v gemini)"; else
|
||||
for c in "${APPDATA:-}/npm/gemini" "/c/Users/${USERNAME:-${USER:-x}}/AppData/Roaming/npm/gemini" \
|
||||
"$HOME/AppData/Roaming/npm/gemini" "/usr/local/bin/gemini" "$HOME/.npm-global/bin/gemini"; do
|
||||
[ -n "$c" ] && [ -x "$c" ] && { GEMINI="$c"; break; }
|
||||
done
|
||||
fi
|
||||
fi
|
||||
[ -z "$GEMINI" ] && { echo "[$SELF] gemini CLI not found (set identity.json gemini.binary, GEMINI=, or install: npm i -g @google/gemini-cli)" >&2; exit 127; }
|
||||
|
||||
# Model: default routing for text; a strong pinned model for verify/review.
|
||||
# gemini-3.1-pro-preview verified available on this account (2026-06-05); overridable.
|
||||
STRONG_MODEL="${GEMINI_MODEL:-gemini-3.1-pro-preview}"
|
||||
|
||||
MODE="${1:-}"; shift 2>/dev/null || true
|
||||
[ -z "$MODE" ] && { echo "usage: $SELF {text|verify|review|review-files|review-diff|image-analyze|search|raw} ..." >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
PF="$TMP/prompt.txt"; OUT="$TMP/out.txt"; ERR="$TMP/err.txt"
|
||||
REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}"
|
||||
|
||||
# gtimeout on macOS (brew coreutils), timeout elsewhere.
|
||||
TIMEOUT_CMD="timeout"
|
||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||
TIMEOUT_CMD="$(command -v gtimeout 2>/dev/null || echo timeout)"
|
||||
fi
|
||||
|
||||
# run gemini headless reading the prompt file. $1=timeout secs; rest=extra flags.
|
||||
# stdout -> $OUT, stderr -> $ERR (kept separate so warning/429 noise never reaches
|
||||
# the JSON parser). Never fail the script on gemini's exit code; we judge by output.
|
||||
# Records the invocation so emit_or_fail can replay it once on a transient empty turn.
|
||||
LAST_RUN=()
|
||||
run_gemini() {
|
||||
local to="$1"; shift
|
||||
LAST_RUN=("$to" "$@")
|
||||
"$TIMEOUT_CMD" "$to" "$GEMINI" -p "$(cat "$PF")" -o json --skip-trust "$@" \
|
||||
>"$OUT" 2>"$ERR" </dev/null || true
|
||||
}
|
||||
|
||||
# extract .response from the JSON object starting at the first '{' in $OUT.
|
||||
# Parsed via stdin so Windows python never resolves a git-bash (/c/...) path.
|
||||
#
|
||||
# Some pinned-pro tool-using turns (notably image-analyze) leak the model's
|
||||
# internal reasoning stream into .response: a stray token + a 'thought' marker
|
||||
# followed by 'CRITICAL INSTRUCTION N:' lines, then the real answer. We strip
|
||||
# that preamble ONLY when the signature is clearly present, so clean responses
|
||||
# (text/verify/review/search) pass through byte-for-byte unchanged.
|
||||
gresponse() { "$PY" -c "import json,sys,re,os
|
||||
raw=sys.stdin.read()
|
||||
i=raw.find('{')
|
||||
if i < 0:
|
||||
print(''); sys.exit(0)
|
||||
try:
|
||||
r=json.loads(raw[i:]).get('response','') or ''
|
||||
except Exception:
|
||||
print(''); sys.exit(0)
|
||||
head=r[:40].lower()
|
||||
leak=('thought' in head) or ('critical instruction' in r.lower()[:600])
|
||||
if leak:
|
||||
lines=r.split('\n')
|
||||
keep=[]; dropping=True
|
||||
for ln in lines:
|
||||
s=ln.strip()
|
||||
low=s.lower()
|
||||
if dropping and (
|
||||
low.endswith('thought') or low.startswith('critical instruction')
|
||||
or low.startswith('thought:') or low=='' ):
|
||||
continue
|
||||
dropping=False
|
||||
keep.append(ln)
|
||||
cleaned='\n'.join(keep).strip()
|
||||
r=cleaned if cleaned else r.strip()
|
||||
# AGY_CLEAN: aggressive prefix scrub for tool-using turns (image-analyze), which
|
||||
# can fuse a stray stream/tool token onto the front of the answer (e.g. '.',
|
||||
# '.94>', 'uem_image_0_0_png}'). Off by default so text/verify/review/search are
|
||||
# byte-exact. We only remove a junk run that ends in a stream delimiter (} > :)
|
||||
# or a lone leading punctuation char, immediately before the first real sentence.
|
||||
if os.environ.get('AGY_CLEAN') == '1' and r:
|
||||
# The pro-preview tool loop sometimes prepends a numbered/markdown reasoning
|
||||
# block before the actual answer. If a clear answer pivot follows such a
|
||||
# preamble, keep from the pivot onward (the user-facing answer).
|
||||
if re.search(r'(?im)^\s*\d+[.)]\s', r) or 'thought' in r[:60].lower():
|
||||
pivs=list(re.finditer(r'(?i)(Based on the image\b|\*\*Answer:?\*\*|The image (?:contains|shows|displays)\b)', r))
|
||||
if pivs:
|
||||
r=r[pivs[-1].start():]
|
||||
m=re.match(r'^[^\n]{0,40}?(?:\.png\)|\.jpe?g\)|[}>:)])\s*([\"A-Z].*)$', r, re.S)
|
||||
if m and m.group(1):
|
||||
r=m.group(1)
|
||||
else:
|
||||
# a short leading junk run (ASCII punctuation/digits or non-Latin stream
|
||||
# tokens) before a capitalized/quoted sentence start. Bounded length so we
|
||||
# never eat a real lowercase sentence or real prose.
|
||||
m=re.match(r'^(?:[^A-Za-z\"]|[^\x00-\x7f]){1,8}([A-Z\"].*)$', r, re.S)
|
||||
if m and m.group(1):
|
||||
r=m.group(1)
|
||||
r=r.strip()
|
||||
print(r)" < "$OUT"; }
|
||||
|
||||
# detect an auth failure in stderr (so we can give a precise remediation hint)
|
||||
auth_failed() { grep -qiE 'oauth|unauthor|authenticat|login|credential|invalid_grant|401' "$ERR" 2>/dev/null; }
|
||||
|
||||
emit_or_fail() { # print .response, or retry once on a transient empty turn, else fail
|
||||
local txt; txt="$(gresponse)"
|
||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi
|
||||
# Auth failures won't be fixed by a retry — report immediately.
|
||||
if auth_failed; then
|
||||
echo "[$SELF] Gemini auth error — run 'gemini' interactively and choose 'Login with Google', then retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Gemini occasionally returns an empty turn (or absorbs a 429 backoff into the
|
||||
# timeout). Replay the identical call once before giving up.
|
||||
if [ ${#LAST_RUN[@]} -gt 0 ]; then
|
||||
echo "[$SELF] empty response — retrying once..." >&2
|
||||
run_gemini "${LAST_RUN[@]}"
|
||||
txt="$(gresponse)"
|
||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi
|
||||
if auth_failed; then
|
||||
echo "[$SELF] Gemini auth error — run 'gemini' interactively and choose 'Login with Google', then retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "[$SELF] no response from gemini. stderr tail:" >&2
|
||||
tail -3 "$ERR" >&2 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Copy target files into an included temp workspace dir so gemini's read_file can
|
||||
# reach them regardless of .gitignore / workspace sandbox. Echoes the included dir.
|
||||
INCLUDE_DIR="$TMP/inbox"
|
||||
prep_includes() { mkdir -p "$INCLUDE_DIR"; }
|
||||
|
||||
case "$MODE" in
|
||||
text|verify)
|
||||
SRC=""
|
||||
if [ "${1:-}" = "--prompt-file" ]; then
|
||||
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
||||
SRC="$(cat "$2")"
|
||||
else
|
||||
SRC="${1:-}"
|
||||
fi
|
||||
[ -z "$SRC" ] && { echo "usage: $SELF $MODE \"<prompt>\" | $SELF $MODE --prompt-file <path>" >&2; exit 2; }
|
||||
if [ "$MODE" = "verify" ]; then
|
||||
printf 'You are an adversarial reviewer giving an independent second opinion. Evaluate the following claim/finding/document: try hard to find any way it is WRONG, incomplete, unsupported, or overstated. Then give a clear VERDICT (e.g. correct / partly correct / incorrect) plus specific justification. Answer in text only; do not use any tools.\n\nContent:\n%s' "$SRC" > "$PF"
|
||||
run_gemini 180 -m "$STRONG_MODEL"
|
||||
else
|
||||
printf 'Answer the following directly in text. Do not use any tools.\n\n%s' "$SRC" > "$PF"
|
||||
run_gemini 180
|
||||
fi
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review|file)
|
||||
[ -z "${1:-}" ] && { echo "usage: $SELF review <file-path> [instructions]" >&2; exit 2; }
|
||||
target="$1"
|
||||
instr="${2:-Give an independent, critical review of this file: accuracy, gaps/omissions, bugs, and concrete improvements. Be specific.}"
|
||||
# GOTCHA: a relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) --
|
||||
# NOT a submodule/subdir. "server/src/x.rs" relative to a submodule fails ("file not found")
|
||||
# unless CWD is that submodule. Pass ABSOLUTE paths for submodule/subtree files.
|
||||
if [ -f "$target" ]; then resolved="$target"
|
||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||
else echo "[$SELF] file not found: $target" >&2; exit 2; fi
|
||||
prep_includes
|
||||
base="$(basename "$resolved")"
|
||||
cp -f "$resolved" "$INCLUDE_DIR/$base"
|
||||
tgt_win="$(winpath "$INCLUDE_DIR/$base")"
|
||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
printf 'Use your read_file tool to read the file at this absolute path, then perform the task and stop. Do not modify anything.\nPath: %s\n\nTask: %s' "$tgt_win" "$instr" > "$PF"
|
||||
run_gemini 240 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$inc_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review-files)
|
||||
instr='Independently review these files together as a unit: correctness/bugs, gaps, cross-file consistency, and concrete improvements. Be specific and cite file:line.'
|
||||
files=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
*) files+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
[ ${#files[@]} -eq 0 ] && { echo "usage: $SELF review-files [-i \"instructions\"] <file> [file ...]" >&2; exit 2; }
|
||||
prep_includes
|
||||
list=""
|
||||
declare -A seen=()
|
||||
# GOTCHA: each relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) --
|
||||
# NOT a submodule/subdir. Paths relative to a submodule fail unless CWD is that submodule.
|
||||
# Pass ABSOLUTE paths for submodule/subtree files (e.g. build the list with `find "$(pwd)/..."`).
|
||||
for f in "${files[@]}"; do
|
||||
if [ -f "$f" ]; then r="$f"
|
||||
elif [ -f "$REPO_ROOT/$f" ]; then r="$REPO_ROOT/$f"
|
||||
else echo "[$SELF] file not found: $f" >&2; exit 2; fi
|
||||
base="$(basename "$r")"
|
||||
# de-collide identical basenames from different dirs
|
||||
if [ -n "${seen[$base]:-}" ]; then
|
||||
n=1; while [ -e "$INCLUDE_DIR/${n}_${base}" ]; do n=$((n+1)); done; base="${n}_${base}"
|
||||
fi
|
||||
seen[$base]=1
|
||||
cp -f "$r" "$INCLUDE_DIR/$base"
|
||||
list+="- $(winpath "$INCLUDE_DIR/$base")
|
||||
"
|
||||
done
|
||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
printf 'Use your read_file tool to read EACH of these files (absolute paths), then perform the task across ALL of them and stop. Do not modify anything.\n\nFiles:\n%s\nTask: %s' "$list" "$instr" > "$PF"
|
||||
run_gemini 300 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$inc_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
review-diff)
|
||||
gdir="$REPO_ROOT"
|
||||
instr='Review this git diff: correctness/bugs introduced, regressions, missing edge cases, and concrete fixes. Focus on the CHANGES. Be specific and cite file:line.'
|
||||
ref=""; pathspec=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-C|--dir) gdir="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
--) shift; while [ $# -gt 0 ]; do pathspec+=("$1"); shift; done ;;
|
||||
*) if [ -z "$ref" ]; then ref="$1"; else pathspec+=("$1"); fi; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$ref" ] && { echo "usage: $SELF review-diff [-C <repo-dir>] [-i \"instr\"] <gitref> [-- <pathspec>]" >&2; exit 2; }
|
||||
[ -d "$gdir" ] || { [ -d "$REPO_ROOT/$gdir" ] && gdir="$REPO_ROOT/$gdir"; }
|
||||
git -C "$gdir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[$SELF] not a git repo: $gdir" >&2; exit 2; }
|
||||
if [ ${#pathspec[@]} -gt 0 ]; then
|
||||
git -C "$gdir" diff "$ref" -- "${pathspec[@]}" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||
else
|
||||
git -C "$gdir" diff "$ref" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||
fi
|
||||
[ -s "$TMP/diff.txt" ] || { echo "[$SELF] empty/failed diff for '$ref' in $gdir: $(head -1 "$TMP/differr.txt" 2>/dev/null)" >&2; exit 1; }
|
||||
gdir_win="$(winpath "$gdir")"
|
||||
{ printf 'Review the following unified git diff. %s\nYou may use your read_file tool on any changed file for full context (paths in the diff are relative to %s; strip the a/ b/ prefixes). Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr" "$gdir_win"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
||||
run_gemini 300 -m "$STRONG_MODEL" --approval-mode plan --include-directories "$gdir_win"
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
image-analyze|image|vision)
|
||||
# Independent second-model VISION. The default flash-lite router hallucinates
|
||||
# image content, so we PIN the pro vision model (STRONG_MODEL) and run with
|
||||
# yolo approval so read_file can execute. The image is copied into an included
|
||||
# temp dir (like the review modes) and handed to Gemini by absolute winpath.
|
||||
[ -z "${1:-}" ] && { echo "usage: $SELF image-analyze <image-path> [\"question\"]" >&2; exit 2; }
|
||||
target="$1"
|
||||
question="${2:-Describe exactly what is in this image.}"
|
||||
if [ -f "$target" ]; then resolved="$target"
|
||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||
else echo "[$SELF] image not found: $target" >&2; exit 2; fi
|
||||
prep_includes
|
||||
base="$(basename "$resolved")"
|
||||
cp -f "$resolved" "$INCLUDE_DIR/$base"
|
||||
img_win="$(winpath "$INCLUDE_DIR/$base")"
|
||||
inc_win="$(winpath "$INCLUDE_DIR")"
|
||||
# Image path goes in via %s (never as a printf format string).
|
||||
printf 'Use your read_file tool to read the image at this absolute path, then describe exactly what you see. Report only what is actually present in the image; do not guess or invent content. Then stop. Do not modify anything.\nImage path: %s\n\nQuestion: %s' "$img_win" "$question" > "$PF"
|
||||
run_gemini 240 -m "$STRONG_MODEL" --approval-mode yolo --include-directories "$inc_win"
|
||||
AGY_CLEAN=1 emit_or_fail
|
||||
;;
|
||||
|
||||
search|websearch)
|
||||
# Google-grounded LIVE web search (mirrors grok xsearch). Gemini's
|
||||
# google_web_search tool works on OAuth; run with yolo so the tool can fire.
|
||||
# Query goes via the prompt file so long queries don't hit shell-quote limits.
|
||||
SRC=""
|
||||
if [ "${1:-}" = "--prompt-file" ]; then
|
||||
[ -f "${2:-}" ] || { echo "[$SELF] prompt file not found: ${2:-}" >&2; exit 2; }
|
||||
SRC="$(cat "$2")"
|
||||
else
|
||||
SRC="${1:-}"
|
||||
fi
|
||||
[ -z "$SRC" ] && { echo "usage: $SELF search \"<query>\" | $SELF search --prompt-file <path>" >&2; exit 2; }
|
||||
printf 'Use your google_web_search tool to find current, live information answering the following, then stop. Answer concisely and ALWAYS include the source URLs you used (a Sources list of full URLs). Do not fabricate URLs.\n\nQuery: %s' "$SRC" > "$PF"
|
||||
run_gemini 180 -m "$STRONG_MODEL" --approval-mode yolo
|
||||
emit_or_fail
|
||||
;;
|
||||
|
||||
raw)
|
||||
"$GEMINI" "$@"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "[$SELF] unknown mode '$MODE' (use text|verify|review|review-files|review-diff|image-analyze|search|raw)" >&2; exit 2 ;;
|
||||
esac
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@ bash "$CLAUDETOOLS_ROOT/.claude/skills/grok/scripts/ask-grok.sh" <mode> ...
|
||||
|------|-------|--------------|
|
||||
| `text` | `ask-grok.sh text "<prompt>"` or `text --prompt-file <path>` | One-shot text answer (independent model). `--prompt-file` for long content (review/summarize a doc). |
|
||||
| `verify` | `ask-grok.sh verify "<claim/finding>"` or `verify --prompt-file <path>` | Adversarial second opinion — Grok tries to REFUTE/find gaps, returns a verdict + reasons. |
|
||||
| `review` | `ask-grok.sh review <file-path> ["<instructions>"]` | Grok reads the file at `<path>` itself (its `read_file` tool, run in the repo) and reviews it — no embedding, handles large files, can pull in referenced files. |
|
||||
| `review` | `ask-grok.sh review <file-path> ["<instructions>"]` | Grok reads the file at `<path>` itself (its `read_file` tool) and reviews it — no embedding, handles large files, can pull in referenced files. Path resolution: absolute, CWD-relative, or relative to `$CLAUDETOOLS_ROOT` — **see the path gotcha below**. Spaces OK. |
|
||||
| `review-files` | `ask-grok.sh review-files [-i "<instr>"] <f1> [f2 …]` | Review a **set** of files together (grok `read_file`s each) — for cross-file consistency or a multi-file change. Same path resolution as `review` (**see gotcha below**); spaces OK. No code passed as a shell arg → no quote hell. |
|
||||
| `review-diff` | `ask-grok.sh review-diff [-C <repo-dir>] [-i "<instr>"] <gitref> [-- <pathspec>]` | Review a **git diff** (`git diff <gitref>` from `<repo-dir>`; default repo root, use `-C` for a submodule e.g. `-C projects/msp-tools/guru-rmm`). The diff goes via the prompt file (not a shell arg); grok can `read_file` changed files for full context (cwd = repo dir). |
|
||||
| `image` | `ask-grok.sh image "<prompt>" [out.png]` | `image_gen` (Imagine) → copies the artifact to `out` (default `grok-image.png`). |
|
||||
| `video` | `ask-grok.sh video "<motion prompt>" <input-image> [out.mp4]` | `image_to_video` on an input image → copies to `out`. ~60-90s. |
|
||||
| `xsearch` | `ask-grok.sh xsearch "<query>"` | Live `web_search` + X/Twitter tools; returns text with citations. |
|
||||
@@ -44,6 +46,18 @@ media **retrieves the artifact by sessionId** from
|
||||
recovered even when a headless run reports `stopReason: Cancelled` before echoing
|
||||
the path (a known finalization quirk of the `-p` mode).
|
||||
|
||||
> [!WARNING]
|
||||
> **Path gotcha for `review` / `review-files` (this has bitten us repeatedly).**
|
||||
> A relative path is resolved against ONLY two roots: your **current directory**,
|
||||
> and **`$CLAUDETOOLS_ROOT`** (`/d/claudetools`). It is NOT resolved against a
|
||||
> submodule or any arbitrary subdir. So a path like `server/src/api/auth.rs` that
|
||||
> is relative to a submodule (e.g. `projects/msp-tools/guru-connect/`) fails with
|
||||
> `file not found` whenever your CWD isn't that submodule — even though the file
|
||||
> obviously exists. **When reviewing files in a submodule or any non-root subtree,
|
||||
> pass ABSOLUTE paths** (e.g. build the list with `find "$(pwd)/server/src" -name '*.rs'`
|
||||
> from inside the submodule). Absolute paths always work regardless of CWD and
|
||||
> tolerate spaces. (For `review-diff`, the analogous fix is `-C <submodule-dir>`.)
|
||||
|
||||
## Machine availability (fleet)
|
||||
|
||||
Grok is **per-machine** — the skill syncs fleet-wide but the binary does not. Availability is gated by `identity.json` (per-machine, gitignored):
|
||||
@@ -55,7 +69,7 @@ Grok is **per-machine** — the skill syncs fleet-wide but the binary does not.
|
||||
```
|
||||
|
||||
- If `grok.installed` is `false` (or the block is absent), `ask-grok.sh` exits **3** with routing guidance instead of failing obscurely. Claude on such a machine should NOT attempt local Grok.
|
||||
- **Current fleet Grok host: `GURU-5070`** — the only machine with Grok installed right now. When others get it, set their `identity.json` `grok` block (and update this line).
|
||||
- **Fleet Grok hosts: `GURU-5070`, `GURU-BEAST-ROG`** — machines with Grok installed. When others get it, set their `identity.json` `grok` block (and update this line).
|
||||
|
||||
**Remote routing (NOT yet wired):** a non-host machine cannot run Grok locally. To fulfill a Grok request from elsewhere, route it to the host (`GURU-5070`). Candidate channels: GuruRMM agent command execution (`/rmm` — GURU-5070 is enrolled; the hard part is shipping image/video artifacts back), `grok agent serve` (WebSocket relay), or a coord-API job queue. Until that's built, Grok requests originate on the host machine.
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
# ask-grok.sh image "<prompt>" [out.png] # image_gen -> copy artifact to out
|
||||
# ask-grok.sh video "<prompt>" <input-image> [out.mp4] # image_to_video on input image
|
||||
# ask-grok.sh xsearch "<query>" # live X/Twitter + web search
|
||||
# ask-grok.sh review <file> [instructions] # grok read_file's + reviews one file
|
||||
# ask-grok.sh review-files [-i "instr"] <f1> [f2 ...] # review a SET of files together
|
||||
# ask-grok.sh review-diff [-C <repo-dir>] [-i "instr"] <gitref> [-- <pathspec>] # review a git diff
|
||||
# ask-grok.sh raw <grok args...> # escape hatch (passes through)
|
||||
#
|
||||
# Exit: 0 ok, 1 no result/artifact, 2 usage, 127 grok not found.
|
||||
@@ -26,6 +29,17 @@ SELF="ask-grok"
|
||||
PY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)"
|
||||
[ -z "$PY" ] && { echo "[$SELF] python (py/python/python3) required for JSON parsing" >&2; exit 127; }
|
||||
|
||||
# --- path conversion: native-Windows path for grok.exe args (no-op off Windows) ---
|
||||
# grok.exe is a native Windows binary; Git Bash hands it POSIX paths (/tmp, /c/.., /d/..)
|
||||
# that it cannot resolve. cygpath -w converts to C:\... form on MSYS/Cygwin; on Linux/macOS
|
||||
# (native grok, already-correct paths) it passes through unchanged. Doing this explicitly
|
||||
# removes reliance on MSYS's heuristic auto-conversion (which breaks on spaces/edge cases).
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
winpath() { cygpath -w -- "$1" 2>/dev/null || printf '%s' "$1"; }
|
||||
else
|
||||
winpath() { printf '%s' "$1"; }
|
||||
fi
|
||||
|
||||
# --- identity.json (per-machine, gitignored) declares whether grok is installed here ---
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
||||
IDFILE=""
|
||||
@@ -62,7 +76,7 @@ fi
|
||||
[ -z "$GROK" ] && { echo "[$SELF] grok CLI not found (set identity.json grok.binary, GROK=, or install grok)" >&2; exit 127; }
|
||||
|
||||
MODE="${1:-}"; shift 2>/dev/null || true
|
||||
[ -z "$MODE" ] && { echo "usage: $SELF {text|verify|image|video|xsearch|raw} ..." >&2; exit 2; }
|
||||
[ -z "$MODE" ] && { echo "usage: $SELF {text|verify|image|video|xsearch|review|review-files|review-diff|raw} ..." >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
WORK="$TMP/work"; mkdir -p "$WORK"
|
||||
@@ -80,8 +94,10 @@ fi
|
||||
|
||||
run_grok() {
|
||||
local to="$1"; shift
|
||||
"$TIMEOUT_CMD" "$to" "$GROK" --prompt-file "$PF" --output-format json \
|
||||
--permission-mode dontAsk --no-subagents --no-plan --cwd "$RUN_CWD" "$@" \
|
||||
# Hand grok native-Windows paths (cygpath); MSYS leaves already-Windows paths alone,
|
||||
# so conversion is deterministic and space-safe.
|
||||
"$TIMEOUT_CMD" "$to" "$GROK" --prompt-file "$(winpath "$PF")" --output-format json \
|
||||
--permission-mode dontAsk --no-subagents --no-plan --cwd "$(winpath "$RUN_CWD")" "$@" \
|
||||
>"$OUT" 2>"$TMP/err.txt" || true
|
||||
}
|
||||
|
||||
@@ -98,6 +114,40 @@ find_artifact() {
|
||||
ls -t "$HOME/.grok/sessions/"*"/$1/$2/"* 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# --- self-healing embed fallback for review modes -----------------------------
|
||||
# The review/review-files/review-diff modes default to letting grok read the
|
||||
# target files/diff ITSELF (read_file tool) — this works on grok >=0.2.22 and
|
||||
# avoids stuffing large files into the prompt. But on grok 0.2.20 headless
|
||||
# read_file wasn't wired, so those runs came back EMPTY (silent failure). The
|
||||
# text/verify modes never had this problem because they EMBED all content inline
|
||||
# (no tools). To survive a future regression of that kind, each review mode below
|
||||
# retries ONCE with the file/diff contents embedded inline (the no-tools text
|
||||
# path) when the grok-reads-files run returns empty — but only when the payload
|
||||
# is small enough to safely inline (EMBED_FALLBACK_MAX_BYTES). Over that size we
|
||||
# keep the existing behavior (report "no result") rather than blow up the prompt.
|
||||
EMBED_FALLBACK_MAX_BYTES=262144 # ~256KB ceiling for inlining content into the prompt
|
||||
|
||||
# byte size of one or more files, summed; prints an integer (0 if none readable).
|
||||
bytes_of_files() {
|
||||
local total=0 n
|
||||
for f in "$@"; do
|
||||
n="$(wc -c < "$f" 2>/dev/null || echo 0)"
|
||||
n="${n//[^0-9]/}"; [ -z "$n" ] && n=0
|
||||
total=$(( total + n ))
|
||||
done
|
||||
printf '%s' "$total"
|
||||
}
|
||||
|
||||
# Run grok in the no-tools text path against the already-built $PF, capturing the
|
||||
# result into the caller's variable. Mirrors the text-mode invocation (web search
|
||||
# off, short turn budget) since everything it needs is already in the prompt.
|
||||
# Resets RUN_CWD to a neutral working dir so no tool-reachable cwd is implied.
|
||||
embed_fallback_run() {
|
||||
RUN_CWD="$WORK"
|
||||
run_grok 240 --disable-web-search --max-turns 3
|
||||
jfield text
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
text|verify)
|
||||
# content from --prompt-file <path> (good for long docs) or the positional arg
|
||||
@@ -156,12 +206,127 @@ case "$MODE" in
|
||||
[ -z "${1:-}" ] && { echo "usage: $SELF review <file-path> [instructions]" >&2; exit 2; }
|
||||
target="$1"
|
||||
instr="${2:-Give an independent, critical review of this file: accuracy, gaps/omissions, and concrete improvements. Be specific.}"
|
||||
# Grok reads the file itself (no embedding) -- run it in the repo so read_file resolves repo-relative paths.
|
||||
[ -f "$target" ] || [ -f "$REPO_ROOT/$target" ] || { echo "[$SELF] file not found: $target" >&2; exit 2; }
|
||||
# Grok reads the file itself (no embedding). Resolve to an absolute path (as given, or
|
||||
# relative to $REPO_ROOT), then hand grok the native-Windows ABSOLUTE path so read_file
|
||||
# works regardless of cwd, and tolerates absolute paths and spaces.
|
||||
# GOTCHA: a relative path resolves against ONLY CWD or $REPO_ROOT ($CLAUDETOOLS_ROOT) --
|
||||
# NOT a submodule/subdir. "server/src/x.rs" relative to a submodule fails unless CWD is
|
||||
# that submodule. Pass ABSOLUTE paths for submodule/subtree files.
|
||||
if [ -f "$target" ]; then resolved="$target"
|
||||
elif [ -f "$REPO_ROOT/$target" ]; then resolved="$REPO_ROOT/$target"
|
||||
else echo "[$SELF] file not found: $target" >&2; exit 2; fi
|
||||
tgt_win="$(winpath "$resolved")"
|
||||
RUN_CWD="$REPO_ROOT"
|
||||
printf 'Use your read_file tool to read the file at this path (relative to your current directory), then do the task and stop. You may also read closely-related files it references if that helps. Do not modify anything.\nPath: %s\n\nTask: %s' "$target" "$instr" > "$PF"
|
||||
printf 'Use your read_file tool to read the file at this absolute path, then do the task and stop. You may also read closely-related files it references if that helps. Do not modify anything.\nPath: %s\n\nTask: %s' "$tgt_win" "$instr" > "$PF"
|
||||
run_grok 240 --max-turns 12
|
||||
txt="$(jfield text)"
|
||||
if [ -z "$txt" ]; then
|
||||
# grok-reads-files came back empty (possible read_file regression) -> retry
|
||||
# ONCE with the file contents embedded inline, if small enough to inline.
|
||||
sz="$(bytes_of_files "$resolved")"
|
||||
if [ "$sz" -le "$EMBED_FALLBACK_MAX_BYTES" ]; then
|
||||
echo "[$SELF] empty result; retrying with file embedded inline (${sz}B)" >&2
|
||||
{ printf 'Review the following file. Answer in text only; do not use tools. Do not modify anything.\nPath: %s\n\nTask: %s\n\n=== BEGIN FILE ===\n' "$resolved" "$instr"; cat "$resolved"; printf '\n=== END FILE ===\n'; } > "$PF"
|
||||
txt="$(embed_fallback_run)"
|
||||
else
|
||||
echo "[$SELF] embed-fallback skipped: file is ${sz}B (> ${EMBED_FALLBACK_MAX_BYTES}B threshold)" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; else
|
||||
echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
||||
;;
|
||||
review-files)
|
||||
# review-files [-i "instructions"] <file> [file ...]
|
||||
# Reviews a SET of files together (grok read_file's each). Paths may be absolute,
|
||||
# CWD-relative, or relative to $REPO_ROOT ($CLAUDETOOLS_ROOT); spaces are fine.
|
||||
# GOTCHA: a relative path is NOT resolved against a submodule/subdir -- "server/src/x.rs"
|
||||
# relative to a submodule fails ("file not found") unless CWD is that submodule. Pass
|
||||
# ABSOLUTE paths for submodule/subtree files. No code passed as a shell arg -> no quote hell.
|
||||
instr='Independently review these files together as a unit: correctness/bugs, gaps, cross-file consistency, and concrete improvements. Be specific and cite file:line.'
|
||||
files=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
*) files+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
[ ${#files[@]} -eq 0 ] && { echo "usage: $SELF review-files [-i \"instructions\"] <file> [file ...]" >&2; exit 2; }
|
||||
list=""
|
||||
resolved_files=() # POSIX paths, kept for the embed fallback (sizing + cat)
|
||||
for f in "${files[@]}"; do
|
||||
if [ -f "$f" ]; then r="$f"
|
||||
elif [ -f "$REPO_ROOT/$f" ]; then r="$REPO_ROOT/$f"
|
||||
else echo "[$SELF] file not found: $f" >&2; exit 2; fi
|
||||
resolved_files+=("$r")
|
||||
list+="- $(winpath "$r")
|
||||
"
|
||||
done
|
||||
RUN_CWD="$REPO_ROOT"
|
||||
printf 'Use your read_file tool to read EACH of these files (absolute paths), then perform the task across ALL of them and stop. Do not modify anything.\n\nFiles:\n%s\nTask: %s' "$list" "$instr" > "$PF"
|
||||
run_grok 300 --max-turns 24
|
||||
txt="$(jfield text)"
|
||||
if [ -z "$txt" ]; then
|
||||
# read_file path empty -> retry ONCE with all file contents embedded inline,
|
||||
# if the combined size is under the inline threshold.
|
||||
sz="$(bytes_of_files "${resolved_files[@]}")"
|
||||
if [ "$sz" -le "$EMBED_FALLBACK_MAX_BYTES" ]; then
|
||||
echo "[$SELF] empty result; retrying with ${#resolved_files[@]} file(s) embedded inline (${sz}B)" >&2
|
||||
{
|
||||
printf 'Review the following files together as a unit. Answer in text only; do not use tools. Do not modify anything.\n\nTask: %s\n' "$instr"
|
||||
for r in "${resolved_files[@]}"; do
|
||||
printf '\n=== BEGIN FILE: %s ===\n' "$r"; cat "$r"; printf '\n=== END FILE: %s ===\n' "$r"
|
||||
done
|
||||
} > "$PF"
|
||||
txt="$(embed_fallback_run)"
|
||||
else
|
||||
echo "[$SELF] embed-fallback skipped: combined files are ${sz}B (> ${EMBED_FALLBACK_MAX_BYTES}B threshold)" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; else
|
||||
echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
||||
;;
|
||||
review-diff)
|
||||
# review-diff [-C <repo-dir>] [-i "instructions"] <gitref> [-- <pathspec...>]
|
||||
# Reviews `git diff <gitref>` from <repo-dir> (default repo root; use -C for a submodule,
|
||||
# e.g. -C projects/msp-tools/guru-rmm). The diff is written to the prompt file (not a shell
|
||||
# arg) -> no quote hell; grok can read_file changed files for full context (cwd=repo-dir).
|
||||
gdir="$REPO_ROOT"
|
||||
instr='Review this git diff: correctness/bugs introduced, regressions, missing edge cases, and concrete fixes. Focus on the CHANGES. Be specific and cite file:line.'
|
||||
ref=""; pathspec=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-C|--dir) gdir="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
-i|--instr) instr="${2:-}"; shift 2 2>/dev/null || shift ;;
|
||||
--) shift; while [ $# -gt 0 ]; do pathspec+=("$1"); shift; done ;;
|
||||
*) if [ -z "$ref" ]; then ref="$1"; else pathspec+=("$1"); fi; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$ref" ] && { echo "usage: $SELF review-diff [-C <repo-dir>] [-i \"instr\"] <gitref> [-- <pathspec>]" >&2; exit 2; }
|
||||
[ -d "$gdir" ] || { [ -d "$REPO_ROOT/$gdir" ] && gdir="$REPO_ROOT/$gdir"; }
|
||||
git -C "$gdir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[$SELF] not a git repo: $gdir" >&2; exit 2; }
|
||||
if [ ${#pathspec[@]} -gt 0 ]; then
|
||||
git -C "$gdir" diff "$ref" -- "${pathspec[@]}" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||
else
|
||||
git -C "$gdir" diff "$ref" > "$TMP/diff.txt" 2>"$TMP/differr.txt"
|
||||
fi
|
||||
[ -s "$TMP/diff.txt" ] || { echo "[$SELF] empty/failed diff for '$ref' in $gdir: $(head -1 "$TMP/differr.txt" 2>/dev/null)" >&2; exit 1; }
|
||||
RUN_CWD="$gdir" # changed-file paths in the diff are relative to this repo root
|
||||
{ printf 'Review the following unified git diff. %s\nYou may use read_file on any changed file (paths in the diff are relative to your current directory; strip the a/ b/ prefixes) for full context. Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
||||
run_grok 300 --max-turns 20
|
||||
txt="$(jfield text)"
|
||||
if [ -z "$txt" ]; then
|
||||
# If even the diff review (which already embeds the diff but invites read_file
|
||||
# for context) came back empty, retry ONCE in the strict no-tools text path
|
||||
# with just the diff inline, provided the diff is under the inline threshold.
|
||||
sz="$(bytes_of_files "$TMP/diff.txt")"
|
||||
if [ "$sz" -le "$EMBED_FALLBACK_MAX_BYTES" ]; then
|
||||
echo "[$SELF] empty result; retrying with diff embedded inline, no tools (${sz}B)" >&2
|
||||
{ printf 'Review the following unified git diff. %s\nAnswer in text only; do not use tools. Do not modify anything.\n\n=== BEGIN DIFF ===\n' "$instr"; cat "$TMP/diff.txt"; printf '\n=== END DIFF ===\n'; } > "$PF"
|
||||
txt="$(embed_fallback_run)"
|
||||
else
|
||||
echo "[$SELF] embed-fallback skipped: diff is ${sz}B (> ${EMBED_FALLBACK_MAX_BYTES}B threshold)" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "$txt" ]; then printf '%s\n' "$txt"; else
|
||||
echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi
|
||||
;;
|
||||
@@ -169,5 +334,5 @@ case "$MODE" in
|
||||
"$GROK" "$@"
|
||||
;;
|
||||
*)
|
||||
echo "[$SELF] unknown mode '$MODE' (use text|verify|image|video|xsearch|raw)" >&2; exit 2 ;;
|
||||
echo "[$SELF] unknown mode '$MODE' (use text|verify|image|video|xsearch|review|review-files|review-diff|raw)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -1,152 +1,185 @@
|
||||
---
|
||||
name: human-flow
|
||||
description: >
|
||||
A UI/UX scanner that specializes in detecting interaction patterns unintuitive or inefficient for humans using a mouse and keyboard.
|
||||
Expands on frontend-design and impeccable by focusing on real human workflow friction: motor control (Fitts's Law, target sizing, precision),
|
||||
discoverability (affordances, hover vs always-visible), keyboard parity (full navigation and activation without mouse),
|
||||
feedback loops (immediate state changes, error recovery), task efficiency (click/keystroke count, context switches),
|
||||
and forgiving interaction models. It produces structured reports with code locations, "why this feels bad for a human" explanations,
|
||||
and specific, actionable recommendations to make mouse+keyboard workflows smoother, faster, and more intuitive.
|
||||
Use when reviewing or building any interactive UI, especially data-heavy tools, dashboards, lists, forms, and complex workflows.
|
||||
user-invocable: true
|
||||
argument-hint: "[scan|audit|report] [target path or component]"
|
||||
---
|
||||
|
||||
# human-flow — Human Mouse + Keyboard Workflow Scanner
|
||||
|
||||
This skill is a specialized scanner for **human intuition and ergonomics** in pointer + keyboard interfaces. It goes beyond visual polish, code quality, and general UX heuristics (covered by `frontend-design` and `impeccable`) to focus on what actually feels *clunky, hidden, or frustrating* when a real person is driving with a mouse and keyboard.
|
||||
|
||||
**Core Philosophy**
|
||||
- Humans have limited precision, attention, and patience.
|
||||
- Mouse users hate tiny targets, hidden controls, and precision clicking.
|
||||
- Keyboard users hate missing focus, no activation keys, and mouse-only gestures.
|
||||
- Good workflow design makes the *anticipated next action* obvious and low-effort with either input method.
|
||||
- The best interfaces feel "just right" — large enough targets, immediate feedback, discoverable without hunting, and consistent models.
|
||||
|
||||
It is **mandatory** to consider human-flow when any mouse or keyboard interaction is involved.
|
||||
|
||||
## When to Invoke
|
||||
|
||||
- Before or after implementing interactive features (buttons, tables, lists, modals, forms, drag, selection).
|
||||
- When reviewing a dashboard, admin tool, or data-heavy UI (e.g. session lists, machine management).
|
||||
- To audit an existing interface for workflow friction.
|
||||
- As a complement to `impeccable critique` / `audit` and `frontend-design` validation.
|
||||
|
||||
Run via natural language ("human-flow scan the sessions table", "run human-flow audit on the dashboard components") or explicitly.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------------------|-------------|
|
||||
| `scan [target]` | Quick static + heuristic scan of files or directories for mouse/keyboard friction. Produces a prioritized report. |
|
||||
| `audit [target]` | Deeper pass: combines code analysis, component review, and workflow walkthroughs. Scores intuitiveness and suggests specific refactors. |
|
||||
| `fancy [target]` | **"Fancy as fuck" mode** — a second, beauty- and elegance-focused pass. Evaluates opportunities for tasteful delight (transitions, micro-interactions, hover states, view transitions, loading experiences, etc.), determines appropriateness, and suggests refinements/polish. |
|
||||
| `report [target]` | Generate a clean, user-facing markdown report suitable for sharing with designers/devs. |
|
||||
|
||||
If no command, defaults to `scan` on the provided target (or current frontend dir).
|
||||
|
||||
You can combine: e.g. run `scan` first for friction, then `fancy` for delight opportunities.
|
||||
|
||||
## Usage in Practice (for the Agent)
|
||||
|
||||
1. Resolve the target (file, component, page, or directory of .tsx/.jsx/.css/.ts).
|
||||
2. Load the heuristics from `references/`.
|
||||
3. Use code tools (read_file, grep, list_dir) + the scanner script if helpful to find candidate patterns.
|
||||
4. For each finding:
|
||||
- Cite exact file:line or component.
|
||||
- Explain *why it is unintuitive for a human with mouse and/or keyboard*.
|
||||
- Give a concrete "better for humans" recommendation (with example diff or pattern when useful).
|
||||
5. Prioritize by impact on common workflows (high-frequency actions first).
|
||||
6. End with an overall "Human Workflow Score" (0-10) and top 3-5 recommended changes.
|
||||
|
||||
Always separate **mouse friction** and **keyboard friction** in reports, then **combined workflow** issues.
|
||||
|
||||
## Heuristics (Core Categories)
|
||||
|
||||
The full detailed list with examples and detection guidance lives in `references/mouse-keyboard-heuristics.md`.
|
||||
|
||||
High-level categories the scanner always checks:
|
||||
|
||||
- **Target Size & Motor Precision** (Fitts's Law)
|
||||
- **Discoverability & Affordance** (does it look clickable/tappable? do secondary actions hide?)
|
||||
- **Hover vs Always-Visible** (actions that require mouse hover to even see options)
|
||||
- **Keyboard Parity & Activation** (can you do everything the mouse can, with reasonable keys?)
|
||||
- **Focus & Navigation Flow** (tab order, focus trapping, visible focus, escape hatches)
|
||||
- **Feedback & State Transitions** (does the UI react immediately and clearly to every mouse/keyboard action?)
|
||||
- **Selection & Multi-Action Models** (row click vs checkbox, drag vs buttons — are they consistent and forgiving?)
|
||||
- **Workflow Efficiency** (number of steps, precision required, dead space, context loss for common tasks)
|
||||
- **Error Prevention & Recovery** (destructive actions, undo, clear "I didn't mean that" paths)
|
||||
- **Density vs Clarity** (too much crammed into small areas forcing careful mousing)
|
||||
|
||||
The scanner is **opinionated toward making the happy path for a human operator faster and less error-prone**, even if it means slightly more visual weight or always-visible controls.
|
||||
|
||||
## Scripts & Tooling
|
||||
|
||||
- `scripts/scan.mjs` — runnable Node scanner.
|
||||
- `node scripts/scan.mjs --path <target>` → friction mode (default)
|
||||
- `node scripts/scan.mjs --path <target> --fancy` → fancy mode (collects signals + prompts for the qualitative beauty pass)
|
||||
- The agent is expected to supplement with semantic understanding (reading full components, understanding the user task flow in the app) and the rich references. The fancy pass is intentionally more qualitative than the friction scanner.
|
||||
|
||||
## Integration with Other Skills
|
||||
|
||||
- Run **after** `frontend-design` visual validation and **alongside** `impeccable critique/audit`.
|
||||
- Use `stop-slop` thinking when generating any example fixes or new component code.
|
||||
- Can feed findings into `impeccable polish` or `harden` passes.
|
||||
|
||||
## Output Format (Preferred)
|
||||
|
||||
```markdown
|
||||
## Human-Flow Scan: <target>
|
||||
|
||||
**Overall Human Workflow Score:** 6.5/10 (Mouse: 7/10, Keyboard: 6/10)
|
||||
|
||||
### High Friction (P0)
|
||||
- **File:** ...:123 — Hover-only row actions
|
||||
Why unintuitive: Secondary actions (End, Control) are at 0.5 opacity until hover. A keyboard user or rushed mouse user scanning the list will miss or struggle to target them.
|
||||
Human impact: Common task (ending a session) requires extra precision and discovery step.
|
||||
Recommendation: ...
|
||||
```
|
||||
|
||||
See `references/report-template.md` for the full structure.
|
||||
|
||||
## "Fancy as Fuck" Mode (`fancy`)
|
||||
|
||||
This is a deliberate second (or standalone) pass focused on **beauty, refinement, and elegant interaction**.
|
||||
|
||||
### Philosophy
|
||||
- Not every interface needs (or should have) fancy elements. The first question is always: *"Does this beauty make the experience more useful?"*
|
||||
- **Core principle**: Don't be pretty just to be pretty. In the course of being as useful as possible, do it with panache.
|
||||
- "Useful decoration" is explicitly welcomed on surfaces where beauty can amplify comprehension, guidance, emotional reassurance, decision speed, or long-term connection to the product.
|
||||
- On dense internal tools (operator consoles, admin dashboards): favor *restrained luxury* and precision. Think "high-end instrument." Only add panache where it clearly helps the operator.
|
||||
- On other surfaces (onboarding, public tools, marketing moments, creative experiences): more room for expressive, delightful "useful decoration."
|
||||
- Fancy must **serve the human workflow**. It should increase perceived quality, clarity, emotional satisfaction, or effectiveness without adding cognitive load, slowing tasks, or hurting performance/accessibility.
|
||||
- "Fancy" includes (but is not limited to): transitions & easing, micro-interactions, hover/focus states, page/view transitions (View Transitions API), loading & skeleton experiences, selection/confirmation moments, empty/success states, scroll reveals, depth/layering, typography shifts, cursor feedback, and tasteful celebration moments.
|
||||
|
||||
### How the Fancy Pass Works
|
||||
1. Assess appropriateness for the surface and user context.
|
||||
2. Look for **opportunities** where a small amount of elegant motion or feedback would make interactions feel more premium and alive.
|
||||
3. Critique existing attempts that are half-baked, janky, overused, or performance-negative.
|
||||
4. Suggest specific, high-craft refinements and polish.
|
||||
5. Always respect `prefers-reduced-motion` and provide graceful fallbacks.
|
||||
|
||||
See `references/fancy-as-fuck.md` for the full set of beauty/elegance heuristics, appropriateness guidelines, and examples.
|
||||
|
||||
### Recommended Invocation Pattern
|
||||
```bash
|
||||
# Friction first
|
||||
human-flow scan the dashboard
|
||||
|
||||
# Then delight
|
||||
human-flow fancy the dashboard
|
||||
```
|
||||
|
||||
The output of a `fancy` pass should live in its own section of the report (or a dedicated delight report) and feed nicely into `impeccable polish` or `delight` work.
|
||||
|
||||
## Creating / Extending
|
||||
|
||||
- Add new heuristics to `references/mouse-keyboard-heuristics.md` (with detection hints and "better human workflow" examples).
|
||||
- Add fancy/delights ideas to `references/fancy-as-fuck.md`.
|
||||
- Update the scanner script for new static patterns (fancy detection is intentionally more qualitative).
|
||||
- The skill is designed to be extended — new categories of mouse/keyboard friction **and** opportunities for tasteful elegance are welcome.
|
||||
|
||||
**Remember:** The goal is not "perfect accessibility" in isolation or "pretty UI". It is **making the actual anticipated physical and cognitive workflow of a human with a mouse and a keyboard feel natural, fast, and low-friction** — and, when appropriate, doing so with panache and high craft. Beauty that serves usefulness is excellent. Beauty for its own sake is noise.
|
||||
---
|
||||
name: human-flow
|
||||
description: "UI/UX scanner for mouse+keyboard interaction friction: Fitts's Law/target sizing, discoverability/affordances, keyboard parity, feedback loops, task efficiency, forgiving interactions. Produces reports with code locations + fixes. Use when reviewing/building interactive UI (dashboards, lists, forms, complex workflows)."
|
||||
user-invocable: true
|
||||
argument-hint: "[scan|audit|report] [target path or component]"
|
||||
---
|
||||
|
||||
# human-flow — Human Mouse + Keyboard Workflow Scanner
|
||||
|
||||
This skill is a specialized scanner for **human intuition and ergonomics** in pointer + keyboard interfaces. It goes beyond visual polish, code quality, and general UX heuristics (covered by `frontend-design` and `impeccable`) to focus on what actually feels *clunky, hidden, or frustrating* when a real person is driving with a mouse and keyboard.
|
||||
|
||||
**Core Philosophy**
|
||||
- Humans have limited precision, attention, and patience.
|
||||
- Mouse users hate tiny targets, hidden controls, and precision clicking.
|
||||
- Keyboard users hate missing focus, no activation keys, and mouse-only gestures.
|
||||
- Good workflow design makes the *anticipated next action* obvious and low-effort with either input method.
|
||||
- The best interfaces feel "just right" — large enough targets, immediate feedback, discoverable without hunting, and consistent models.
|
||||
|
||||
It is **mandatory** to consider human-flow when any mouse or keyboard interaction is involved.
|
||||
|
||||
## When to Invoke
|
||||
|
||||
- Before or after implementing interactive features (buttons, tables, lists, modals, forms, drag, selection).
|
||||
- When reviewing a dashboard, admin tool, or data-heavy UI (e.g. session lists, machine management).
|
||||
- To audit an existing interface for workflow friction.
|
||||
- As a complement to `impeccable critique` / `audit` and `frontend-design` validation.
|
||||
|
||||
Run via natural language ("human-flow scan the sessions table", "run human-flow audit on the dashboard components") or explicitly.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------------------|-------------|
|
||||
| `scan [target]` | AST-powered scan of files/directories for workflow friction. Produces a 0-10 Friction Index report. |
|
||||
| `audit [target]` | Deeper pass: combines AST analysis, component review, and state-flow audit. |
|
||||
| `elevate [target]` | **Polish & redesign pass.** Goes beyond friction to make a UI top-notch: information hierarchy, signature moment, action gravity, lonely states, density, rhythm, type, tokens, depth/finish, motion — and flags when a screen should be **redesigned, not patched**. Produces an Elevation Index + prioritized tiers (Quick Wins / Elevations / Redesign Candidates). Add `--redesign` to emphasize structural restructuring. See `references/polish-and-redesign.md`. |
|
||||
| `fix [target]` | **DISABLED (advisory only for now).** Auto-apply is off — the AST code generator reprints whole files and produces noisy diffs. Use the scan/report output and have an agent apply the fixes surgically. Will be revisited with a surgical (string-splice) editor. |
|
||||
| `fancy [target]` | **"Fancy as fuck" mode** — elegance pass with a calibrated Restraint-o-Meter. |
|
||||
| `report [target]` | Generate a formatted markdown report with the Friction Index rubric. |
|
||||
|
||||
If no command, defaults to `scan` on the provided target.
|
||||
|
||||
## Friction Index (0-10)
|
||||
|
||||
The scan produces an objective score based on weighted deductions:
|
||||
- **Motor (3.0)**: Target size, precision, Fitts's Law.
|
||||
- **Cognitive (2.5)**: Discoverability, affordance, consistency.
|
||||
- **Keyboard (2.5)**: Accessibility, focus flow, parity.
|
||||
- **Feedback (2.0)**: Visual response, state transitions.
|
||||
|
||||
Score = 10 - Σ(IssueSeverity * DimensionWeight)
|
||||
|
||||
You can combine: e.g. run `scan` first for friction, then `fancy` for delight opportunities.
|
||||
|
||||
## Usage in Practice (for the Agent)
|
||||
|
||||
1. Resolve the target (file, component, page, or directory of .tsx/.jsx/.css/.ts).
|
||||
2. Load the heuristics from `references/`.
|
||||
3. Use code tools (read_file, grep, list_dir) + the scanner script if helpful to find candidate patterns.
|
||||
4. For each finding:
|
||||
- Cite exact file:line or component.
|
||||
- Explain *why it is unintuitive for a human with mouse and/or keyboard*.
|
||||
- Give a concrete "better for humans" recommendation (with example diff or pattern when useful).
|
||||
5. Prioritize by impact on common workflows (high-frequency actions first).
|
||||
6. End with an overall "Human Workflow Score" (0-10) and top 3-5 recommended changes.
|
||||
|
||||
Always separate **mouse friction** and **keyboard friction** in reports, then **combined workflow** issues.
|
||||
|
||||
## Heuristics (Core Categories)
|
||||
|
||||
The full detailed list with examples and detection guidance lives in `references/mouse-keyboard-heuristics.md`.
|
||||
|
||||
High-level categories the scanner always checks:
|
||||
|
||||
- **Target Size & Motor Precision** (Fitts's Law)
|
||||
- **Discoverability & Affordance** (does it look clickable/tappable? do secondary actions hide?)
|
||||
- **Hover vs Always-Visible** (actions that require mouse hover to even see options)
|
||||
- **Keyboard Parity & Activation** (can you do everything the mouse can, with reasonable keys?)
|
||||
- **Focus & Navigation Flow** (tab order, focus trapping, visible focus, escape hatches)
|
||||
- **Feedback & State Transitions** (does the UI react immediately and clearly to every mouse/keyboard action?)
|
||||
- **Selection & Multi-Action Models** (row click vs checkbox, drag vs buttons — are they consistent and forgiving?)
|
||||
- **Workflow Efficiency** (number of steps, precision required, dead space, context loss for common tasks)
|
||||
- **Error Prevention & Recovery** (destructive actions, undo, clear "I didn't mean that" paths)
|
||||
- **Density vs Clarity** (too much crammed into small areas forcing careful mousing)
|
||||
|
||||
The scanner is **opinionated toward making the happy path for a human operator faster and less error-prone**, even if it means slightly more visual weight or always-visible controls.
|
||||
|
||||
## Scripts & Tooling
|
||||
|
||||
- `scripts/scan.mjs` — runnable Node scanner.
|
||||
- `node scripts/scan.mjs --path <target>` → friction mode (default)
|
||||
- `node scripts/scan.mjs --path <target> --fancy` → fancy mode (collects signals + prompts for the qualitative beauty pass)
|
||||
- The agent is expected to supplement with semantic understanding (reading full components, understanding the user task flow in the app) and the rich references. The fancy pass is intentionally more qualitative than the friction scanner.
|
||||
|
||||
## Integration with Other Skills
|
||||
|
||||
- Run **after** `frontend-design` visual validation and **alongside** `impeccable critique/audit`.
|
||||
- Use `stop-slop` thinking when generating any example fixes or new component code.
|
||||
- Can feed findings into `impeccable polish` or `harden` passes.
|
||||
|
||||
## Output Format (Preferred)
|
||||
|
||||
```markdown
|
||||
## Human-Flow Scan: <target>
|
||||
|
||||
**Overall Human Workflow Score:** 6.5/10 (Mouse: 7/10, Keyboard: 6/10)
|
||||
|
||||
### High Friction (P0)
|
||||
- **File:** ...:123 — Hover-only row actions
|
||||
Why unintuitive: Secondary actions (End, Control) are at 0.5 opacity until hover. A keyboard user or rushed mouse user scanning the list will miss or struggle to target them.
|
||||
Human impact: Common task (ending a session) requires extra precision and discovery step.
|
||||
Recommendation: ...
|
||||
```
|
||||
|
||||
See `references/report-template.md` for the full structure.
|
||||
|
||||
## "Elevate" Mode (`elevate`) — Polish & Redesign
|
||||
|
||||
Where `scan` finds what *hurts*, `elevate` finds what's *missing to be excellent* — and
|
||||
decides when a screen is beyond polishing and should be **restructured**. It exists because
|
||||
the maintainer is not a designer: after an `elevate` pass, the UI should feel/look/act as if
|
||||
a senior product designer + UI expert + UX team planned it.
|
||||
|
||||
It is primarily an **agent judgment pass** seeded by static signals — read the component,
|
||||
understand the user's task, score each dimension 1–5, then prescribe the concrete better
|
||||
version (a tweak, or a sketched redesign). The 12 heuristics, the scoring model, and the
|
||||
output shape live in `references/polish-and-redesign.md`. In brief:
|
||||
|
||||
- **12 heuristics:** Hierarchy & Visual Anchors · Signature Moment · Action Gravity ·
|
||||
Narrative Coherence · Lonely States (empty/error/loading/success) · Progressive
|
||||
Disclosure & Density · Spacing Rhythm · Typographic Scale · Token Fidelity · Surface/
|
||||
Depth/Finish · Intentional Motion · Redesign Triggers.
|
||||
- **Elevation Index (0–10):** weighted score, with Hierarchy / Signature / Action Gravity /
|
||||
Narrative weighted heaviest.
|
||||
- **Redesign Urgency (0–5):** if ≥ 4, lead with a Structural Audit ("restructure, don't
|
||||
polish") and a sketched alternative layout/component tree.
|
||||
- **Prioritized, not dumped:** `Opportunity = ImpactWeight × (5 − score)`; present the top
|
||||
5–7 as **Quick Wins / Elevations / Redesign Candidates**, each citing file + signal +
|
||||
exact replacement.
|
||||
|
||||
Recommended sequence: `scan` (kill friction) → `elevate` (reach top-notch / decide redesign)
|
||||
→ `fancy` (calibrated delight on top).
|
||||
|
||||
## "Fancy as Fuck" Mode (`fancy`)
|
||||
|
||||
This is a deliberate second (or standalone) pass focused on **beauty, refinement, and elegant interaction**.
|
||||
|
||||
### Philosophy
|
||||
- Not every interface needs (or should have) fancy elements. The first question is always: *"Does this beauty make the experience more useful?"*
|
||||
- **Core principle**: Don't be pretty just to be pretty. In the course of being as useful as possible, do it with panache.
|
||||
- "Useful decoration" is explicitly welcomed on surfaces where beauty can amplify comprehension, guidance, emotional reassurance, decision speed, or long-term connection to the product.
|
||||
- On dense internal tools (operator consoles, admin dashboards): favor *restrained luxury* and precision. Think "high-end instrument." Only add panache where it clearly helps the operator.
|
||||
- On other surfaces (onboarding, public tools, marketing moments, creative experiences): more room for expressive, delightful "useful decoration."
|
||||
- Fancy must **serve the human workflow**. It should increase perceived quality, clarity, emotional satisfaction, or effectiveness without adding cognitive load, slowing tasks, or hurting performance/accessibility.
|
||||
- "Fancy" includes (but is not limited to): transitions & easing, micro-interactions, hover/focus states, page/view transitions (View Transitions API), loading & skeleton experiences, selection/confirmation moments, empty/success states, scroll reveals, depth/layering, typography shifts, cursor feedback, and tasteful celebration moments.
|
||||
|
||||
### How the Fancy Pass Works
|
||||
1. Assess appropriateness for the surface and user context.
|
||||
2. Look for **opportunities** where a small amount of elegant motion or feedback would make interactions feel more premium and alive.
|
||||
3. Critique existing attempts that are half-baked, janky, overused, or performance-negative.
|
||||
4. Suggest specific, high-craft refinements and polish.
|
||||
5. Always respect `prefers-reduced-motion` and provide graceful fallbacks.
|
||||
|
||||
See `references/fancy-as-fuck.md` for the full set of beauty/elegance heuristics, appropriateness guidelines, and examples.
|
||||
|
||||
### Recommended Invocation Pattern
|
||||
```bash
|
||||
# Friction first
|
||||
human-flow scan the dashboard
|
||||
|
||||
# Then delight
|
||||
human-flow fancy the dashboard
|
||||
```
|
||||
|
||||
The output of a `fancy` pass should live in its own section of the report (or a dedicated delight report) and feed nicely into `impeccable polish` or `delight` work.
|
||||
|
||||
## Creating / Extending
|
||||
|
||||
- Add new heuristics to `references/mouse-keyboard-heuristics.md` (with detection hints and "better human workflow" examples).
|
||||
- Add fancy/delights ideas to `references/fancy-as-fuck.md`.
|
||||
- Add polish/redesign heuristics to `references/polish-and-redesign.md` (the `elevate` layer).
|
||||
- Update the scanner script for new static patterns (fancy detection is intentionally more qualitative).
|
||||
- The skill is designed to be extended — new categories of mouse/keyboard friction **and** opportunities for tasteful elegance are welcome.
|
||||
|
||||
**Remember:** The goal is not "perfect accessibility" in isolation or "pretty UI". It is **making the actual anticipated physical and cognitive workflow of a human with a mouse and a keyboard feel natural, fast, and low-friction** — and, when appropriate, doing so with panache and high craft. Beauty that serves usefulness is excellent. Beauty for its own sake is noise.
|
||||
|
||||
217
.claude/skills/human-flow/package-lock.json
generated
Normal file
217
.claude/skills/human-flow/package-lock.json
generated
Normal file
@@ -0,0 +1,217 @@
|
||||
{
|
||||
"name": "human-flow",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "human-flow",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,11 @@
|
||||
"scripts": {
|
||||
"scan": "node scripts/scan.mjs",
|
||||
"fancy": "node scripts/scan.mjs --fancy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,24 @@ Before suggesting any fancy element, answer these questions honestly:
|
||||
- Dense internal tools / operator consoles: Favor *restraint and precision*. Think "expensive mechanical instrument" — satisfying, confident, never showy. Over-the-top sparkle or bouncy motion will feel wrong and unprofessional here.
|
||||
- Onboarding, public-facing, marketing, or higher-emotion flows: More permission for expressive, delightful "useful decoration" that makes the experience feel alive and premium while still serving clear user goals.
|
||||
|
||||
## The Restraint-o-Meter
|
||||
|
||||
Calibrate your "Fancy" recommendations using this scale:
|
||||
|
||||
| Level | Profile | Examples |
|
||||
| :--- | :--- | :--- |
|
||||
| **1** | **Clinical** | Zero motion. Immediate cuts. High density. (Log viewers, raw data dumps). |
|
||||
| **2** | **Functional** | Subtle hover states only. (Internal monitoring tools). |
|
||||
| **3** | **Professional** | Standard easings (150-200ms). Skeleton shimmers. (Admin Dashboards, GuruRMM). |
|
||||
| **4** | **Polished** | View Transitions. Subtle card lifts. Optimistic UI. (User-facing settings, consumer tools). |
|
||||
| **5** | **Expressive** | Full shared-element morphs. Physics-based springs. (Onboarding, Marketing). |
|
||||
|
||||
**Guidance**: If you are in an operator console (Level 2-3), avoid any motion that takes > 200ms or that changes element positions significantly.
|
||||
|
||||
---
|
||||
|
||||
## Categories of Elegant Delight
|
||||
## Technical Signals for Fancy Opportunities
|
||||
|
||||
|
||||
### 1. Transitions & Easing (The Foundation)
|
||||
|
||||
|
||||
@@ -193,7 +193,30 @@ Prioritize findings that affect the most frequent user workflows in the product
|
||||
|
||||
---
|
||||
|
||||
## Related Anti-Patterns from Parent Skills
|
||||
## 7. State-Flow Audit (Dynamic Friction)
|
||||
|
||||
**Anti-patterns**:
|
||||
- Elements that jump or shift layout when data loads (layout thrash).
|
||||
- Lack of optimistic UI for frequent, low-risk actions (waiting for server for every checkbox toggle).
|
||||
- "Dead zones" during state transitions where the UI is locked but doesn't look it.
|
||||
|
||||
**Better human workflow**:
|
||||
- Use skeleton screens with consistent dimensions.
|
||||
- Apply optimistic updates with clear rollback on error.
|
||||
- Ensure the "next logical target" is available or signaled as "loading".
|
||||
|
||||
---
|
||||
|
||||
## 8. The Precision Rail & Fumble Zones
|
||||
|
||||
**Anti-patterns**:
|
||||
- Important interactive controls placed in the leftmost 40px or rightmost 40px of a screen with zero padding.
|
||||
- Dense clusters of varied actions in the "Fumble Zone" (corners).
|
||||
|
||||
**Better human workflow**:
|
||||
- Provide at least 16px of "safe padding" on edges.
|
||||
- Group similar actions; keep high-risk actions away from frequent navigation rails.
|
||||
|
||||
|
||||
This skill deliberately overlaps with and specializes rules from `impeccable` (no identical card grids, no hero metrics, strong focus on cognitive load and emotional journey) and `frontend-design` (click targets 44px, hover states, focus states, disabled states).
|
||||
|
||||
|
||||
135
.claude/skills/human-flow/references/polish-and-redesign.md
Normal file
135
.claude/skills/human-flow/references/polish-and-redesign.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Human-Flow Heuristics: Polish & Redesign (the "Elevate" layer)
|
||||
|
||||
The friction heuristics (`mouse-keyboard-heuristics.md`) find what *hurts*. This layer
|
||||
finds what's *missing to be excellent* — and decides when a screen is beyond polishing
|
||||
and should be **restructured**. Synthesized from three independent model passes (Claude,
|
||||
Gemini, Grok), which converged hard on this set.
|
||||
|
||||
**The bar.** The maintainer is not a designer. After an `elevate` pass, the UI should
|
||||
feel/look/act as if a senior product designer + UI expert + UX team planned it. So this
|
||||
layer is *prescriptive*: don't just flag — propose the concrete better version, and when
|
||||
warranted, sketch a redesign.
|
||||
|
||||
**How to run it.** `elevate` is primarily an **agent judgment pass** seeded by static
|
||||
signals — read the component, understand the user's task, then score and prescribe. The
|
||||
scanner can surface signals (heading counts, raw magic-number styles, missing state
|
||||
branches, animation imports) but the call is the agent's.
|
||||
|
||||
Each heuristic below gives: **what it evaluates** (static signal vs. judgment), the
|
||||
**top-notch bar**, and the **prescription** (the move to recommend — tweak *or* redesign).
|
||||
|
||||
---
|
||||
|
||||
## 1. Hierarchy & Visual Anchors
|
||||
- **Evaluates:** Does visual weight follow importance? *Static:* multiple `<h1>`, repeated identical font-size/weight across unrelated text, div-soup without `section`/`article`/landmark structure, 4+ equally-weighted blocks. *Judgment:* does the dominant thing on screen match the primary user goal?
|
||||
- **Top-notch:** Passes the squint test — one clear primary message, 2–3 supporting levels max, scannable in under 3 seconds without reading every line.
|
||||
- **Prescription:** Consolidate to one `h1` + two supporting levels; promote the key value/data to a large semantic heading, demote metadata to caption/secondary. If 4+ blocks compete, restructure to "primary panel + supporting stack."
|
||||
|
||||
## 2. Signature Moment (First 5 Seconds)
|
||||
- **Evaluates:** Quality of the initial viewport / hero / primary card. *Static:* generic header+content vs. a dedicated orientation block; headline present without supporting microcopy or a primary action. *Judgment:* does it answer "what is this and why act now?"
|
||||
- **Top-notch:** Instant orientation plus a functional or emotional hook — headline + one supporting line + primary action, above the fold, zero ambiguity.
|
||||
- **Prescription:** Replace the generic title with a Signature block (outcome-focused headline, one-sentence value, single primary CTA). On an operator tool, make it a task-oriented "what you can do right now" panel.
|
||||
|
||||
## 3. Action Gravity
|
||||
- **Evaluates:** Is the primary action unmistakable? *Static:* count of primary-styled buttons (>1 is a smell), generic copy ("Submit", "Save"), key actions buried in menus. *Judgment:* is the most important action for *this* context the most salient?
|
||||
- **Top-notch:** Exactly one primary action (or a small, clearly-ranked set), visually and positionally dominant; everything else is secondary/tertiary.
|
||||
- **Prescription:** Elevate the one true primary (size, accent, top-right or sticky action bar). Demote the rest to ghost/icon actions. Rewrite labels to outcome verbs ("Publish changes", "Apply filters"). If two actions are genuinely co-primary for different users, put a segmented choice up front.
|
||||
|
||||
## 4. Narrative Coherence
|
||||
- **Evaluates:** Does the screen tell one logical story matching the task sequence? *Static:* JSX section order vs. logical order, competing CTAs at equal weight, unrelated concerns interleaved. *Judgment:* can a user with the stated goal follow a path without backtracking or "why is this here?"
|
||||
- **Top-notch:** Layout order matches the user's decision/task sequence — one primary thread, clear branches, no random panels.
|
||||
- **Prescription:** Reorder to Context → Decision → Confirmation. Move "related items" into a collapsible tray. If the screen serves two unrelated tasks/roles, split into two focused views.
|
||||
|
||||
## 5. The Lonely States (Empty / Zero / Loading / Error / Success)
|
||||
- **Evaluates:** Are non-happy-path states *designed*? *Static:* conditional rendering with only a happy branch, inline "no data" text, no skeleton/spinner, no `<EmptyState>`/`<ErrorState>`. *Judgment:* does each state reduce anxiety and offer the next action?
|
||||
- **Top-notch:** Every state is designed, not defaulted. Empty states explain *why* and offer the most relevant action; errors are specific + retry + support; success confirms without blocking.
|
||||
- **Prescription:** Add a first-class EmptyState (illustration slot + outcome copy + primary CTA). Replace "no results" with a contextual suggestion (closest useful filter or a create flow). Surface the real error reason + retry, never a bare "something went wrong."
|
||||
|
||||
## 6. Progressive Disclosure & Density Tuning
|
||||
- **Evaluates:** Is density managed, secondary info deferred? *Static:* many visible fields/columns/metrics at once, hover-only details, no accordion/tabs/"show more", long flat tables without grouping. *Judgment:* can the primary task complete without seeing ~80% of the content?
|
||||
- **Top-notch:** Critical path is sparse; secondary detail is one expansion/click away; the user controls the level of detail.
|
||||
- **Prescription:** Collapse the bottom ~60% of a long form into an "Advanced" disclosure. Turn a 12-column table into summary cards + "View details" side panel. For dashboards, add tiered views (Summary / Standard / Full) with a per-user density toggle.
|
||||
|
||||
## 7. Spacing Rhythm & Grid
|
||||
- **Evaluates:** Consistent spatial system? *Static:* hardcoded odd values (`margin: 13px`, `p-[17px]`), inconsistent sibling padding, no spacing scale, cramped containers. *Judgment:* does whitespace group related things and separate unrelated ones (proximity)?
|
||||
- **Top-notch:** Strict 4px/8px grid; related elements closer than unrelated ones; generous, intentional breathing room.
|
||||
- **Prescription:** Normalize all raw px to the nearest spacing token; apply a consistent container `gap`; increase separation between unrelated groups so the eye chunks the layout.
|
||||
|
||||
## 8. Typographic Scale & Readability
|
||||
- **Evaluates:** Is text comfortable and contrasted? *Static:* body < 14px, missing `line-height` on prose, grey-on-grey, raw hex colors vs. WCAG AA. *Judgment:* is long-form content actually pleasant to read?
|
||||
- **Top-notch:** Body 16px+, line-height ~1.5–1.6, a clear primary vs. de-emphasized text system, AA contrast minimum.
|
||||
- **Prescription:** Raise body size/line-height; establish a small type scale (display / heading / body / caption); fix low-contrast pairings to meet AA.
|
||||
|
||||
## 9. System Consistency (Token Fidelity)
|
||||
- **Evaluates:** Do styles come from the system, not one-offs? *Static:* raw numbers in classes (`text-[13px]`, `#3f2a1b`), inline styles, 3 near-identical button/card variants, magic numbers in layout. *Judgment:* justified exception vs. drift?
|
||||
- **Top-notch:** 95%+ of visual decisions come from tokens; new components are *composed*, not invented; rare one-offs are commented.
|
||||
- **Prescription:** Replace raw values with the nearest token. Consolidate near-duplicate components into one with size/emphasis variants. Extract recurring patterns (e.g. section header) into a reusable component that enforces rhythm.
|
||||
|
||||
## 10. Surface, Depth & the Finish Layer (Trust Cues)
|
||||
- **Evaluates:** Does it feel finished and trustworthy? *Static:* no elevation shadows on floating elements, no `:active`/press feedback, generic button text, misaligned numbers, missing timestamps/ownership. *Judgment:* does it feel *crafted* or *assembled*?
|
||||
- **Top-notch:** Subtle shadows convey a Z-axis; interactive elements give a tactile press; numbers right-align; actions read as outcomes; small reassurances ("Last synced 2m ago", "Changes saved automatically") remove doubt.
|
||||
- **Prescription:** Add a consistent surface/chrome to the primary area; `active:` press transform on buttons; right-align numerics; add "last updated" context; unify card/section treatment so widgets read as one product.
|
||||
|
||||
## 11. Intentional Motion & Choreography
|
||||
- **Evaluates:** Does motion serve comprehension, not decoration? *Static:* transition/framer-motion usage without variants, multiple simultaneous transforms on load, >300ms on non-modal elements, no `prefers-reduced-motion`. *Judgment:* does each animation have a purpose (reveal, state change, spatial relationship)?
|
||||
- **Top-notch:** Sparse, purposeful, choreographed; entrances/exits respect spatial relationships; honors reduced-motion. (Calibrate intensity with the Restraint-o-Meter in `fancy-as-fuck.md`.)
|
||||
- **Prescription:** Keep only optimistic state transitions (120–180ms ease), modal/drawer enter-exit with backdrop fade, and at most one staggered reveal that aids scanning. Add a reduced-motion guard. If motion is hiding a weak layout, fix the layout first.
|
||||
|
||||
## 12. Redesign Triggers (Beyond Polishing)
|
||||
- **Evaluates:** Can local polish even fix this? *Static + structural:* >6 competing top-level sections; conditional rendering producing 4+ distinct layouts in one file; component >300 lines or >3 nested ternaries; deep nesting to model a simple hierarchy; "TODO: redesign" comments; a view that accreted 3+ features without re-architecture. *Judgment:* is the screen's conceptual model fundamentally broken?
|
||||
- **Top-notch:** One defensible conceptual model; adding the next feature wouldn't need another special case.
|
||||
- **Prescription:** Declare the redesign threshold crossed. Define the information model first (e.g. "Workspace → Item → Activity"), then a master-detail / two-pane layout that absorbs future features. Provide a sketched component tree + data shape. Do **not** patch the 14-section accordion.
|
||||
|
||||
---
|
||||
|
||||
## Scoring: the Elevation Index
|
||||
|
||||
Score each heuristic **1–5** (1 = absent/harmful, 3 = competent, 5 = senior-designer execution).
|
||||
|
||||
- **Elevation Index** (0–10): weighted average of the 12, scaled to 10. Weight the
|
||||
high-user-impact dimensions heaviest — **Hierarchy, Signature Moment, Action Gravity,
|
||||
Narrative Coherence** (×1.5); the rest ×1.0.
|
||||
- **Redesign Urgency** (0–5): a *separate* score driven mainly by **#12 Redesign
|
||||
Triggers**, reinforced by low **Narrative Coherence** and **Progressive Disclosure**.
|
||||
Urgency ≥ 4 ⇒ lead the report with a **Structural Audit** ("this screen has exceeded
|
||||
patch capacity — restructure, don't polish") and a sketched alternative.
|
||||
|
||||
### Prioritize, don't dump
|
||||
For each finding compute **Opportunity = ImpactWeight × (5 − CurrentScore)**, sort
|
||||
descending, and present the top **5–7** concrete moves (rest in an appendix). Group into
|
||||
three tiers:
|
||||
|
||||
| Tier | Contains | Cost |
|
||||
|---|---|---|
|
||||
| **Quick Wins** | spacing, type, token fidelity, finish details | low effort, high return |
|
||||
| **Elevations** | hierarchy, states, motion, depth, disclosure, action gravity | structural/component-level |
|
||||
| **Redesign Candidates** | Redesign Urgency ≥ 4, or multiple high-impact structural heuristics failing | re-architecture |
|
||||
|
||||
Every recommendation must cite the **file/component**, the **signal that triggered it**,
|
||||
and the **exact replacement pattern or new component/layout shape** — not a vague "improve this."
|
||||
|
||||
---
|
||||
|
||||
## Output shape (elevate)
|
||||
```markdown
|
||||
## Human-Flow Elevate: <target>
|
||||
|
||||
**Elevation Index:** 6.4/10 **Redesign Urgency:** 2/5
|
||||
|
||||
[If Urgency >= 4: Structural Audit block first — why patching won't work + sketched redesign]
|
||||
|
||||
### Quick Wins (do this sprint)
|
||||
1. <file:line> — <signal> -> <concrete token/type/spacing/finish fix>
|
||||
|
||||
### Elevations
|
||||
1. <file:component> — <signal> -> <hierarchy/state/motion/disclosure restructure>
|
||||
|
||||
### Redesign Candidates (plan)
|
||||
1. <file/view> — <triggers> -> <new information model + component tree sketch>
|
||||
|
||||
### Scorecard
|
||||
| Heuristic | Score | Top opportunity |
|
||||
```
|
||||
|
||||
Pair with `scan` (fix friction first) and `fancy` (then calibrated delight). `elevate`
|
||||
is the bridge between "no longer painful" and "genuinely top-notch."
|
||||
@@ -7,12 +7,14 @@ Use this structure for all `scan`, `audit`, and `report` outputs.
|
||||
## Human-Flow Report: <Target / Component / Page>
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Scanner**: human-flow v1 (mouse + keyboard intuition focus)
|
||||
**Scope**: <files/components scanned>
|
||||
**Overall Human Workflow Score**: X/10
|
||||
- Mouse Ergonomics: X/10
|
||||
- Keyboard Parity & Efficiency: X/10
|
||||
- Workflow Discoverability & Friction: X/10
|
||||
**Scanner**: human-flow v2 (AST-Powered)
|
||||
**Overall Human Workflow Score**: X/10
|
||||
|
||||
### Friction Index Rubric
|
||||
- **Motor (3.0)**: Target size, precision, travel distance.
|
||||
- **Cognitive (2.5)**: Discoverability, affordance, consistency.
|
||||
- **Keyboard (2.5)**: Accessibility, focus flow, parity.
|
||||
- **Feedback (2.0)**: Visual response, state transitions.
|
||||
|
||||
**Summary**
|
||||
(2-4 sentences: the biggest sources of unintuitive behavior for a human operator using mouse and keyboard, and the net effect on daily workflow.)
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* human-flow scanner
|
||||
* human-flow scanner v2 (AST-Powered)
|
||||
*
|
||||
* Static analysis pass for mouse + keyboard workflow friction.
|
||||
* Expands the spirit of frontend-design and impeccable with a narrow,
|
||||
* human-motor-and-expectation focus.
|
||||
* Sophisticated analysis pass for mouse + keyboard workflow friction.
|
||||
* Uses @babel/parser for deep JSX/TSX understanding.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/scan.mjs --path dashboard/src --format json
|
||||
* node scripts/scan.mjs --path dashboard/src/features/sessions
|
||||
*
|
||||
* It is intentionally lightweight (regex + heuristics) so it can run fast
|
||||
* inside agent loops. The real intelligence comes from the agent combining
|
||||
* these findings with full component reading and task-flow understanding.
|
||||
* node scripts/scan.mjs --path src
|
||||
* node scripts/scan.mjs --path src --fix
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse } from '@babel/parser';
|
||||
import _traverse from '@babel/traverse';
|
||||
import _generate from '@babel/generator';
|
||||
import * as t from '@babel/types';
|
||||
const traverse = _traverse.default;
|
||||
const generate = _generate.default;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -25,12 +26,13 @@ const args = process.argv.slice(2);
|
||||
let targetPath = 'src';
|
||||
let format = 'text';
|
||||
let mode = 'friction'; // 'friction' | 'fancy'
|
||||
let applyFix = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--path' || args[i] === '-p') targetPath = args[++i];
|
||||
if (args[i] === '--format' || args[i] === '-f') format = args[++i];
|
||||
if (args[i] === '--fancy' || args[i] === '--mode=fancy') mode = 'fancy';
|
||||
if (args[i] === '--mode' && args[i + 1] === 'fancy') { mode = 'fancy'; i++; }
|
||||
if (args[i] === '--fix') applyFix = true;
|
||||
}
|
||||
|
||||
const absTarget = path.resolve(process.cwd(), targetPath);
|
||||
@@ -40,16 +42,40 @@ if (!fs.existsSync(absTarget)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// `--fix` auto-apply is DISABLED for now: @babel/generator reprints the whole
|
||||
// AST, producing noisy diffs that touch untouched code. Until it does surgical
|
||||
// edits, run advisory only — agents apply fixes surgically from the report.
|
||||
if (applyFix) {
|
||||
console.error('[INFO] --fix (auto-apply) is disabled; running an advisory scan instead. Apply fixes surgically from the report.');
|
||||
applyFix = false;
|
||||
}
|
||||
|
||||
const findings = [];
|
||||
let fixesApplied = 0;
|
||||
|
||||
// Friction Index Rubric Weights
|
||||
const WEIGHTS = {
|
||||
MOTOR: 3.0,
|
||||
COGNITIVE: 2.5,
|
||||
KEYBOARD: 2.5,
|
||||
FEEDBACK: 2.0
|
||||
};
|
||||
|
||||
const SEVERITY_POINTS = {
|
||||
high: 1.0,
|
||||
medium: 0.5,
|
||||
low: 0.2
|
||||
};
|
||||
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (['node_modules', 'dist', 'build', '.git'].includes(entry.name)) continue;
|
||||
walk(full);
|
||||
} else if (/\.(tsx|jsx|ts|js|css)$/.test(entry.name)) {
|
||||
} else if (/\.(tsx|jsx|ts|js)$/.test(entry.name)) {
|
||||
analyzeFile(full);
|
||||
}
|
||||
}
|
||||
@@ -58,197 +84,256 @@ function walk(dir) {
|
||||
function analyzeFile(file) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const rel = path.relative(process.cwd(), file).replace(/\\/g, '/');
|
||||
const lines = content.split('\n');
|
||||
|
||||
if (mode === 'fancy') {
|
||||
// Fancy / beauty & elegance pass — lighter static signals + prompts for qualitative review
|
||||
let match;
|
||||
|
||||
// Existing transitions / animations (look for opportunities to refine)
|
||||
const hasTransition = /transition:|transition-\w+:|animate-|@keyframes|ViewTransition|view-transition/i.test(content);
|
||||
if (hasTransition) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'fancy-existing',
|
||||
severity: 'info',
|
||||
pattern: 'existing-motion',
|
||||
message: 'This file already contains motion/transition code. Good candidate for the fancy pass to review quality, consistency, and restraint.',
|
||||
humanImpact: 'Existing fancy elements can feel either premium or cheap/janky depending on execution.',
|
||||
suggestion: 'In the fancy pass, evaluate easing curves, durations, performance, reduced-motion respect, and whether the motion serves the human workflow or just decorates.'
|
||||
});
|
||||
}
|
||||
|
||||
// Missing View Transitions API in SPA navigation contexts
|
||||
if (/(useNavigate|navigate\(|<Link|react-router|next\/|router\.push)/i.test(content) && !/document\.startViewTransition|View Transitions|view-transition/i.test(content)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'fancy-opportunity',
|
||||
severity: 'low',
|
||||
pattern: 'missing-view-transitions',
|
||||
message: 'Navigation or view change logic detected without use of the View Transitions API.',
|
||||
humanImpact: 'Page-like changes can feel abrupt or cheap. Modern "ajax-style" smooth transitions between views feel significantly more premium.',
|
||||
suggestion: 'Consider wrapping key navigation with document.startViewTransition() + CSS view-transition-name for elegant morphs or fades. Only where it genuinely improves perceived quality.'
|
||||
});
|
||||
}
|
||||
|
||||
// Basic hover without fancy enhancement
|
||||
if (/:hover\s*\{[^}]*background|transform|box-shadow|scale|opacity/i.test(content)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'fancy-opportunity',
|
||||
severity: 'low',
|
||||
pattern: 'basic-hover',
|
||||
message: 'Hover state exists but may be basic. Opportunity for more elegant micro-interaction.',
|
||||
humanImpact: 'A merely functional hover feels flat. A refined one (subtle lift + shadow + accent) makes the interface feel alive and high-craft.',
|
||||
suggestion: 'Layer tasteful depth (shadow + slight scale or translate) with excellent easing. Keep it restrained, especially in dense data views.'
|
||||
});
|
||||
}
|
||||
|
||||
return; // In fancy mode we mostly collect signals for the agent to do deep qualitative work
|
||||
}
|
||||
|
||||
// === FRICTION MODE (original) ===
|
||||
|
||||
// 1. Small / sm button targets in interactive contexts (very common friction)
|
||||
const smallButton = /size=["']sm["']|<button[^>]*className=.*btn--sm|height:\s*2[0-8]px|min-height:\s*2[0-8]px/g;
|
||||
let match;
|
||||
while ((match = smallButton.exec(content)) !== null) {
|
||||
const lineNo = content.substring(0, match.index).split('\n').length;
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: lineNo,
|
||||
category: 'target-size',
|
||||
severity: 'high',
|
||||
pattern: 'small-button',
|
||||
message: 'Compact "sm" button or very small height used for an action. Frequent actions (especially in lists) become precision targets.',
|
||||
humanImpact: 'Operators must slow down and aim carefully for common tasks. High error rate under time pressure.',
|
||||
suggestion: 'Use default (md) size for primary/frequent actions. For true compact row actions, ensure generous invisible padding or switch to a larger always-visible treatment.'
|
||||
let modified = false;
|
||||
|
||||
try {
|
||||
const ast = parse(content, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript', 'decorators-legacy', 'classProperties'],
|
||||
errorRecovery: true
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Hover-revealed or low-opacity row actions (the classic operator console anti-pattern)
|
||||
if (/\.dt__rowactions|\.rowactions|\.actions\s*\{[^}]*opacity:\s*0\.[0-6]/s.test(content) ||
|
||||
/opacity:\s*0\.[0-6][^}]*hover|hover[^}]*opacity:\s*(1|0\.[7-9])/s.test(content)) {
|
||||
const lineNo = 1; // best effort
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: lineNo,
|
||||
category: 'discoverability',
|
||||
severity: 'high',
|
||||
pattern: 'hover-only-actions',
|
||||
message: 'Row or list actions are dimmed or hidden until hover (or only fully visible on hover).',
|
||||
humanImpact: 'A human scanning a list with eyes + mouse must "paint" every row to discover what they can do. Keyboard users often never see the controls at full strength.',
|
||||
suggestion: 'Raise resting opacity to 0.7–1.0 so actions are scannable at a glance. Or move frequent actions into a dedicated, always-visible column or primary row target. Keep hover only for polish, not discovery.'
|
||||
if (mode === 'fancy') {
|
||||
// Fancy / beauty & elegance pass
|
||||
const hasMotion = /transition:|animate-|@keyframes|framer-motion|ViewTransition/i.test(content);
|
||||
if (hasMotion) {
|
||||
addFinding({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'FEEDBACK',
|
||||
severity: 'low',
|
||||
pattern: 'existing-motion',
|
||||
message: 'Existing motion detected. Review for quality, easing, and restraint.',
|
||||
humanImpact: 'Motion can feel premium or cheap depending on execution.',
|
||||
suggestion: 'Check if easings match the Restraint-o-Meter Level 3-4 (150-250ms).'
|
||||
});
|
||||
}
|
||||
|
||||
// Missing View Transitions in SPA contexts
|
||||
if (/(useNavigate|navigate\(|<Link|router\.push)/i.test(content) && !/document\.startViewTransition/i.test(content)) {
|
||||
addFinding({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'FEEDBACK',
|
||||
severity: 'low',
|
||||
pattern: 'missing-view-transitions',
|
||||
message: 'Navigation detected without View Transitions API.',
|
||||
humanImpact: 'View changes feel abrupt. Transitions feel significantly more premium.',
|
||||
suggestion: 'Wrap navigation in document.startViewTransition() where appropriate.'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
traverse(ast, {
|
||||
JSXOpeningElement(path) {
|
||||
const node = path.node;
|
||||
const name = getComponentName(node);
|
||||
|
||||
// 1. Unlabeled Icon Button (with Fixer)
|
||||
if (isButtonLike(node) && !hasAriaLabel(node)) {
|
||||
const parent = path.parentPath.node;
|
||||
if (parent.children && hasOnlyIconChild(parent.children)) {
|
||||
if (applyFix) {
|
||||
const iconNode = parent.children.find(c => c.type === 'JSXElement');
|
||||
const iconName = getComponentName(iconNode.openingElement);
|
||||
const label = iconName.replace(/Icon$/, '');
|
||||
node.attributes.push(t.jsxAttribute(t.jsxIdentifier('aria-label'), t.stringLiteral(label)));
|
||||
modified = true;
|
||||
fixesApplied++;
|
||||
} else {
|
||||
addFinding({
|
||||
file: rel,
|
||||
line: node.loc.start.line,
|
||||
category: 'COGNITIVE',
|
||||
severity: 'high',
|
||||
pattern: 'unlabeled-icon-button',
|
||||
message: `Button "${name}" contains only an icon but has no aria-label or title.`,
|
||||
humanImpact: 'Keyboard and screen reader users have no way to know what this button does.',
|
||||
suggestion: 'Add an aria-label or title prop describing the action.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Tiny Target Calculator
|
||||
if (isInteractive(node)) {
|
||||
const size = getTargetSize(node);
|
||||
if (size < 32) {
|
||||
addFinding({
|
||||
file: rel,
|
||||
line: node.loc.start.line,
|
||||
category: 'MOTOR',
|
||||
severity: 'high',
|
||||
pattern: 'tiny-target',
|
||||
message: `Interactive element "${name}" has a detected size of ~${size}px.`,
|
||||
humanImpact: 'Small targets require high precision, leading to slower workflows and mis-clicks.',
|
||||
suggestion: 'Increase height/width to at least 32px (ideally 44px) or add generous padding.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Interaction Feedback Missing
|
||||
if (name === 'Button' || name === 'ActionButton') {
|
||||
if (!hasFeedbackProps(node)) {
|
||||
addFinding({
|
||||
file: rel,
|
||||
line: node.loc.start.line,
|
||||
category: 'FEEDBACK',
|
||||
severity: 'medium',
|
||||
pattern: 'missing-feedback-props',
|
||||
message: `Button "${name}" lacks loading or active state props.`,
|
||||
humanImpact: 'Users may be unsure if their click was registered during long operations.',
|
||||
suggestion: 'Add isLoading or active props to provide immediate visual feedback.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Keyboard Parity: onClick without key handler
|
||||
if (hasProp(node, 'onClick') && !isNativeButton(node) && !hasKeyboardProps(node)) {
|
||||
addFinding({
|
||||
file: rel,
|
||||
line: node.loc.start.line,
|
||||
category: 'KEYBOARD',
|
||||
severity: 'high',
|
||||
pattern: 'click-without-keyboard',
|
||||
message: `Custom element "${name}" has onClick but no keyboard handlers (onKeyDown) or tabIndex.`,
|
||||
humanImpact: 'Keyboard users cannot trigger this action, creating a complete blocker for some workflows.',
|
||||
suggestion: 'Add tabIndex={0} and an onKeyDown handler for Enter/Space.'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. onClick without obvious keyboard support on non-native elements
|
||||
const clickNoKeyboard = /onClick=\{[^}]+}\s*(?!.*(onKeyDown|tabIndex|role=))/g;
|
||||
while ((match = clickNoKeyboard.exec(content)) !== null) {
|
||||
const lineNo = content.substring(0, match.index).split('\n').length;
|
||||
// Only flag if it looks like a custom interactive (div, span, custom component in list context)
|
||||
const context = content.substring(Math.max(0, match.index - 80), match.index + 120);
|
||||
if (/<\s*(div|span|tr|td|li|custom|Card|Row)[^>]*onClick|onClick[^>]*<\s*(div|span|tr|td|li|Card|Row)/.test(context)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: lineNo,
|
||||
category: 'keyboard-parity',
|
||||
severity: 'high',
|
||||
pattern: 'click-without-keyboard',
|
||||
message: 'Custom element has onClick but no visible tabIndex/onKeyDown/Enter-Space handling in the immediate area.',
|
||||
humanImpact: 'Keyboard (or mixed mouse+keyboard) users cannot activate the same thing the mouse can without extra workarounds.',
|
||||
suggestion: 'Add tabIndex={0}, onKeyDown handler for Enter/Space, and strong :focus-visible styles. Prefer native <button> when possible.'
|
||||
});
|
||||
if (modified && applyFix) {
|
||||
const output = generate(ast, { retainLines: true }, content);
|
||||
fs.writeFileSync(file, output.code);
|
||||
}
|
||||
} catch (e) {
|
||||
// Graceful degradation: Fallback to regex for critical failures
|
||||
runLegacyRegexScan(content, rel);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Icon-only buttons without accessible name (common with small action icons)
|
||||
const iconButton = /<Button[^>]*>\s*<[^>]+Icon|<\s*button[^>]*>\s*<[^>]+Icon|<[A-Z][^>]*>\s*<[^>]+Icon/g;
|
||||
while ((match = iconButton.exec(content)) !== null) {
|
||||
const lineNo = content.substring(0, match.index).split('\n').length;
|
||||
const nearby = content.substring(Math.max(0, match.index - 30), match.index + 180);
|
||||
if (!/aria-label|title=/.test(nearby)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: lineNo,
|
||||
category: 'discoverability',
|
||||
severity: 'medium',
|
||||
pattern: 'icon-only-no-label',
|
||||
message: 'Icon-only button or action with no aria-label or title.',
|
||||
humanImpact: 'Screen readers and keyboard users (and anyone who forgets what the tiny icon means) have no idea what it does until they activate it.',
|
||||
suggestion: 'Add aria-label (and preferably a visible label or tooltip that works on focus too).'
|
||||
});
|
||||
function addFinding(f) {
|
||||
findings.push(f);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function getComponentName(node) {
|
||||
if (node.name.type === 'JSXIdentifier') return node.name.name;
|
||||
if (node.name.type === 'JSXMemberExpression') return node.name.property.name;
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function isButtonLike(node) {
|
||||
const name = getComponentName(node);
|
||||
return ['button', 'Button', 'IconButton', 'ActionButton'].includes(name) || hasProp(node, 'role', 'button');
|
||||
}
|
||||
|
||||
function isNativeButton(node) {
|
||||
return getComponentName(node) === 'button';
|
||||
}
|
||||
|
||||
function isInteractive(node) {
|
||||
const name = getComponentName(node);
|
||||
return isButtonLike(node) || ['a', 'input', 'select', 'textarea'].includes(name) || hasProp(node, 'onClick');
|
||||
}
|
||||
|
||||
function hasProp(node, propName, value) {
|
||||
return node.attributes.some(attr => {
|
||||
if (attr.type !== 'JSXAttribute') return false;
|
||||
if (attr.name.name !== propName) return false;
|
||||
if (value === undefined) return true;
|
||||
if (attr.value && attr.value.type === 'StringLiteral') return attr.value.value === value;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function hasAriaLabel(node) {
|
||||
return hasProp(node, 'aria-label') || hasProp(node, 'title') || hasProp(node, 'label');
|
||||
}
|
||||
|
||||
function hasOnlyIconChild(children) {
|
||||
const visibleChildren = children.filter(c => c.type !== 'JSXText' || c.value.trim() !== '');
|
||||
if (visibleChildren.length !== 1) return false;
|
||||
const child = visibleChildren[0];
|
||||
if (child.type !== 'JSXElement') return false;
|
||||
const name = getComponentName(child.openingElement);
|
||||
return name.endsWith('Icon') || name === 'Icon';
|
||||
}
|
||||
|
||||
function getTargetSize(node) {
|
||||
let size = 44; // Default
|
||||
node.attributes.forEach(attr => {
|
||||
if (attr.type === 'JSXAttribute' && attr.name.name === 'size') {
|
||||
if (attr.value.value === 'sm' || attr.value.value === 'xs') size = 28;
|
||||
}
|
||||
}
|
||||
if (attr.type === 'JSXAttribute' && attr.name.name === 'className') {
|
||||
const val = attr.value.value || '';
|
||||
if (val.includes('btn--sm') || val.includes('h-6') || val.includes('h-4')) size = 24;
|
||||
}
|
||||
});
|
||||
return size;
|
||||
}
|
||||
|
||||
// 5. Very narrow status / action columns (precision rail)
|
||||
if (/width:\s*2[0-9]px|width:\s*30px|padding-left:\s*0 !important/.test(content) && /status|actions|select/i.test(content)) {
|
||||
findings.push({
|
||||
function hasFeedbackProps(node) {
|
||||
return hasProp(node, 'loading') || hasProp(node, 'isLoading') || hasProp(node, 'active');
|
||||
}
|
||||
|
||||
function hasKeyboardProps(node) {
|
||||
return hasProp(node, 'onKeyDown') || hasProp(node, 'onKeyPress') || hasProp(node, 'tabIndex');
|
||||
}
|
||||
|
||||
function runLegacyRegexScan(content, rel) {
|
||||
// Simple fallback for files that fail AST parsing
|
||||
if (/onClick=\{[^}]+}\s*(?!.*(onKeyDown|tabIndex|role=))/g.test(content)) {
|
||||
addFinding({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'target-size',
|
||||
severity: 'medium',
|
||||
pattern: 'narrow-rail',
|
||||
message: 'Very narrow column (status, select, or actions rail) used for interactive or important visual elements.',
|
||||
humanImpact: 'Mouse must be extremely precise to hit the control or even read the status comfortably.',
|
||||
suggestion: 'Widen the rail or make the entire left edge a larger hit area (see dt__checkwrap pattern). Status can be visual + text on hover/focus.'
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Row that is fully clickable + internal small actions (mis-click risk)
|
||||
if (/onRowClick|onClick.*row|tr.*onClick/.test(content) && /dt__rowactions|rowactions/.test(content)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
line: 1,
|
||||
category: 'workflow',
|
||||
severity: 'medium',
|
||||
pattern: 'row-click-plus-internal-actions',
|
||||
message: 'Whole row is clickable (for detail/open) while also containing small action buttons inside the row.',
|
||||
humanImpact: 'Easy to accidentally trigger the row action when aiming for the small icon (or vice versa). Classic source of "I didn\'t mean to open that".',
|
||||
suggestion: 'Make the primary row action very clearly the dominant target (bigger visual weight, different treatment). Or stop making the whole row clickable and use a dedicated primary button + separate secondary actions.'
|
||||
category: 'KEYBOARD',
|
||||
severity: 'high',
|
||||
pattern: 'regex-click-without-keyboard',
|
||||
message: 'Detected onClick without keyboard support via fallback scanner.',
|
||||
humanImpact: 'Potential keyboard blocker.',
|
||||
suggestion: 'Manually review for keyboard parity.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Start Scan
|
||||
walk(absTarget);
|
||||
|
||||
// Deduplicate similar findings per file
|
||||
const seen = new Set();
|
||||
const uniqueFindings = findings.filter(f => {
|
||||
const key = `${f.file}:${f.pattern}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (format === 'json') {
|
||||
console.log(JSON.stringify({ target: absTarget, mode, findings: uniqueFindings }, null, 2));
|
||||
if (applyFix) {
|
||||
console.log(`\nFixed ${fixesApplied} mechanical issues across the target.`);
|
||||
} else {
|
||||
const title = mode === 'fancy'
|
||||
? `Human-Flow "Fancy as Fuck" Signals for: ${absTarget}`
|
||||
: `Human-Flow Scan Results for: ${absTarget}`;
|
||||
|
||||
console.log(`${title}\n`);
|
||||
// Calculate Score
|
||||
const scoreDeductions = findings.reduce((acc, f) => {
|
||||
const dim = f.category;
|
||||
const points = SEVERITY_POINTS[f.severity] * WEIGHTS[dim];
|
||||
acc[dim] = (acc[dim] || 0) + points;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (uniqueFindings.length === 0) {
|
||||
if (mode === 'fancy') {
|
||||
console.log('No obvious static fancy signals detected.\nThis is normal — the real fancy pass is qualitative. Load references/fancy-as-fuck.md and evaluate the target for beauty, elegance, and appropriate delight opportunities.');
|
||||
} else {
|
||||
console.log('No obvious mouse/keyboard friction patterns detected by static rules.\nRun a full agent review with the references/heuristics.md for deeper semantic issues.');
|
||||
}
|
||||
const totalDeduction = Object.values(scoreDeductions).reduce((a, b) => a + b, 0);
|
||||
const finalScore = Math.max(0, Math.min(10, 10 - totalDeduction)).toFixed(1);
|
||||
|
||||
if (format === 'json') {
|
||||
console.log(JSON.stringify({ target: absTarget, score: finalScore, findings }, null, 2));
|
||||
} else {
|
||||
uniqueFindings.forEach((f, i) => {
|
||||
console.log(`${i + 1}. [${f.severity.toUpperCase()}] ${f.category} — ${f.pattern}`);
|
||||
console.log(` File: ${f.file}:${f.line}`);
|
||||
console.log(` ${f.message}`);
|
||||
console.log(` Human impact: ${f.humanImpact}`);
|
||||
console.log(` Suggestion: ${f.suggestion}\n`);
|
||||
});
|
||||
console.log(`## Human-Flow Scan: ${targetPath}`);
|
||||
console.log(`**Overall Human Workflow Score: ${finalScore}/10**\n`);
|
||||
|
||||
if (findings.length === 0) {
|
||||
console.log('[OK] No friction detected. Workflow is clean.');
|
||||
} else {
|
||||
findings.forEach((f, i) => {
|
||||
console.log(`${i + 1}. [${f.severity.toUpperCase()}] ${f.category} — ${f.pattern}`);
|
||||
console.log(` File: ${f.file}:${f.line}`);
|
||||
console.log(` ${f.message}`);
|
||||
console.log(` Human impact: ${f.humanImpact}`);
|
||||
console.log(` Suggestion: ${f.suggestion}\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const exitCode = mode === 'fancy' ? 0 : (uniqueFindings.length > 0 ? 2 : 0);
|
||||
process.exit(exitCode);
|
||||
@@ -1,168 +1,168 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: "Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks."
|
||||
argument-hint: "[{{command_hint}}] [target]"
|
||||
user-invocable: true
|
||||
allowed-tools:
|
||||
- Bash(npx impeccable *)
|
||||
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
|
||||
---
|
||||
|
||||
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
|
||||
|
||||
## Setup
|
||||
|
||||
Before any design work or file edits:
|
||||
|
||||
1. Load context (PRODUCT.md / DESIGN.md) via the loader script.
|
||||
2. Identify the register and load the matching register reference (brand.md or product.md).
|
||||
3. **If the user invoked a sub-command (e.g. `craft`, `shape`, `audit`), load its reference file too.** This is non-negotiable: `craft` without `craft.md` loaded means you'll skip the shape-and-confirm step the user expects.
|
||||
|
||||
Skipping these produces generic output that ignores the project.
|
||||
|
||||
### 1. Context gathering
|
||||
|
||||
Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd).
|
||||
|
||||
- **PRODUCT.md**: required. Users, brand, tone, anti-references, strategic principles.
|
||||
- **DESIGN.md**: optional, strongly recommended. Colors, typography, elevation, components.
|
||||
|
||||
Load both in one call:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/load-context.mjs
|
||||
```
|
||||
|
||||
Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from.
|
||||
|
||||
If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `{{command_prefix}}impeccable teach` or `{{command_prefix}}impeccable document` (they rewrite the files), or the user manually edited one.
|
||||
|
||||
`{{command_prefix}}impeccable live` already warms context via `live.mjs`. If you've run `live.mjs`, don't also run `load-context.mjs` this session.
|
||||
|
||||
If PRODUCT.md is missing, empty, or placeholder (`[TODO]` markers, <200 chars): run `{{command_prefix}}impeccable teach`, then resume the user's original task with the fresh context. If the original task was `{{command_prefix}}impeccable craft`, resume into `{{command_prefix}}impeccable shape` before any implementation work.
|
||||
|
||||
If DESIGN.md is missing: nudge once per session (*"Run `{{command_prefix}}impeccable document` for more on-brand output"*), then proceed.
|
||||
|
||||
### 2. Register
|
||||
|
||||
Every design task is **brand** (marketing, landing, campaign, long-form content, portfolio: design IS the product) or **product** (app UI, admin, dashboard, tool: design SERVES the product).
|
||||
|
||||
Identify before designing. Priority: (1) cue in the task itself ("landing page" vs "dashboard"); (2) the surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. First match wins.
|
||||
|
||||
If PRODUCT.md lacks the `register` field (legacy), infer it once from its "Users" and "Product Purpose" sections, then cache the inferred value for the session. Suggest the user run `{{command_prefix}}impeccable teach` to add the field explicitly.
|
||||
|
||||
Load the matching reference: [reference/brand.md](reference/brand.md) or [reference/product.md](reference/product.md). The shared design laws below apply to both.
|
||||
|
||||
## Shared design laws
|
||||
|
||||
Apply to every design, both registers. Match implementation complexity to the aesthetic vision: maximalism needs elaborate code, minimalism needs precision. Interpret creatively. Vary across projects; never converge on the same choices. {{model}} is capable of extraordinary work. Don't hold back.
|
||||
|
||||
### Color
|
||||
|
||||
- Use OKLCH. Reduce chroma as lightness approaches 0 or 100; high chroma at extremes looks garish.
|
||||
- Never use `#000` or `#fff`. Tint every neutral toward the brand hue (chroma 0.005–0.01 is enough).
|
||||
- Pick a **color strategy** before picking colors. Four steps on the commitment axis:
|
||||
- **Restrained**: tinted neutrals + one accent ≤10%. Product default; brand minimalism.
|
||||
- **Committed**: one saturated color carries 30–60% of the surface. Brand default for identity-driven pages.
|
||||
- **Full palette**: 3–4 named roles, each used deliberately. Brand campaigns; product data viz.
|
||||
- **Drenched**: the surface IS the color. Brand heroes, campaign pages.
|
||||
- The "one accent ≤10%" rule is Restrained only. Committed / Full palette / Drenched exceed it on purpose. Don't collapse every design to Restrained by reflex.
|
||||
|
||||
### Theme
|
||||
|
||||
Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe."
|
||||
|
||||
Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does.
|
||||
|
||||
"Observability dashboard" does not force an answer. "SRE glancing at incident severity on a 27-inch monitor at 2am in a dim room" does. Run the sentence, not the category.
|
||||
|
||||
### Typography
|
||||
|
||||
- Cap body line length at 65–75ch.
|
||||
- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
|
||||
|
||||
### Layout
|
||||
|
||||
- Vary spacing for rhythm. Same padding everywhere is monotony.
|
||||
- Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong.
|
||||
- Don't wrap everything in a container. Most things don't need one.
|
||||
|
||||
### Motion
|
||||
|
||||
- Don't animate CSS layout properties.
|
||||
- Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic.
|
||||
|
||||
### Absolute bans
|
||||
|
||||
Match-and-refuse. If you're about to write any of these, rewrite the element with different structure.
|
||||
|
||||
- **Side-stripe borders.** `border-left` or `border-right` greater than 1px as a colored accent on cards, list items, callouts, or alerts. Never intentional. Rewrite with full borders, background tints, leading numbers/icons, or nothing.
|
||||
- **Gradient text.** `background-clip: text` combined with a gradient background. Decorative, never meaningful. Use a single solid color. Emphasis via weight or size.
|
||||
- **Glassmorphism as default.** Blurs and glass cards used decoratively. Rare and purposeful, or nothing.
|
||||
- **The hero-metric template.** Big number, small label, supporting stats, gradient accent. SaaS cliché.
|
||||
- **Identical card grids.** Same-sized cards with icon + heading + text, repeated endlessly.
|
||||
- **Modal as first thought.** Modals are usually laziness. Exhaust inline / progressive alternatives first.
|
||||
|
||||
### Copy
|
||||
|
||||
- Every word earns its place. No restated headings, no intros that repeat the title.
|
||||
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
|
||||
|
||||
### The AI slop test
|
||||
|
||||
If someone could look at this interface and say "AI made that" without doubt, it's failed. Cross-register failures are the absolute bans above. Register-specific failures live in each reference.
|
||||
|
||||
**Category-reflex check.** Run at two altitudes; the second one catches what the first one misses.
|
||||
|
||||
- **First-order:** if someone could guess the theme + palette from the category alone ("observability → dark blue", "healthcare → white + teal", "finance → navy + gold", "crypto → neon on black"), it's the first training-data reflex. Rework the scene sentence and color strategy until the answer isn't obvious from the domain.
|
||||
- **Second-order:** if someone could guess the aesthetic family from category-plus-anti-references ("AI workflow tool that's not SaaS-cream → editorial-typographic", "fintech that's not navy-and-gold → terminal-native dark mode"), it's the trap one tier deeper. The first reflex was avoided; the second wasn't. Rework until both answers are not obvious. The brand register's [reflex-reject aesthetic lanes](reference/brand.md) list catches the currently-saturated families.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Category | Description | Reference |
|
||||
|---|---|---|---|
|
||||
| `craft [feature]` | Build | Shape, then build a feature end-to-end | [reference/craft.md](reference/craft.md) |
|
||||
| `shape [feature]` | Build | Plan UX/UI before writing code | [reference/shape.md](reference/shape.md) |
|
||||
| `teach` | Build | Set up PRODUCT.md and DESIGN.md context | [reference/teach.md](reference/teach.md) |
|
||||
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
|
||||
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
|
||||
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
|
||||
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
|
||||
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
|
||||
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
|
||||
| `distill [target]` | Refine | Strip to essence, remove complexity | [reference/distill.md](reference/distill.md) |
|
||||
| `harden [target]` | Refine | Production-ready: errors, i18n, edge cases | [reference/harden.md](reference/harden.md) |
|
||||
| `onboard [target]` | Refine | Design first-run flows, empty states, activation | [reference/onboard.md](reference/onboard.md) |
|
||||
| `animate [target]` | Enhance | Add purposeful animations and motion | [reference/animate.md](reference/animate.md) |
|
||||
| `colorize [target]` | Enhance | Add strategic color to monochromatic UIs | [reference/colorize.md](reference/colorize.md) |
|
||||
| `typeset [target]` | Enhance | Improve typography hierarchy and fonts | [reference/typeset.md](reference/typeset.md) |
|
||||
| `layout [target]` | Enhance | Fix spacing, rhythm, and visual hierarchy | [reference/layout.md](reference/layout.md) |
|
||||
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
|
||||
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
|
||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
|
||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||
|
||||
Plus two management commands: `pin <command>` and `unpin <command>`, detailed below.
|
||||
|
||||
### Routing rules
|
||||
|
||||
1. **No argument**: render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do.
|
||||
2. **First word matches a command**: load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match**: general design invocation. Apply the setup steps, shared design laws, and the loaded register reference, using the full argument as context.
|
||||
|
||||
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `{{command_prefix}}impeccable`.
|
||||
|
||||
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `teach` as a blocker, finish teach, refresh context, then resume the original command and target.
|
||||
|
||||
## Pin / Unpin
|
||||
|
||||
**Pin** creates a standalone shortcut so `{{command_prefix}}<command>` invokes `{{command_prefix}}impeccable <command>` directly. **Unpin** removes it. The script writes to every harness directory present in the project.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/pin.mjs <pin|unpin> <command>
|
||||
```
|
||||
|
||||
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
|
||||
---
|
||||
name: impeccable
|
||||
description: "Design, redesign, critique, audit, or polish a frontend interface (sites, landing pages, dashboards, app UI, components, forms, onboarding, empty states). Covers UX review, visual hierarchy, IA, accessibility, performance, responsive, theming, typography, spacing, color, motion, copy, design systems/tokens. Not for backend/non-UI."
|
||||
argument-hint: "[{{command_hint}}] [target]"
|
||||
user-invocable: true
|
||||
allowed-tools:
|
||||
- Bash(npx impeccable *)
|
||||
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
|
||||
---
|
||||
|
||||
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
|
||||
|
||||
## Setup
|
||||
|
||||
Before any design work or file edits:
|
||||
|
||||
1. Load context (PRODUCT.md / DESIGN.md) via the loader script.
|
||||
2. Identify the register and load the matching register reference (brand.md or product.md).
|
||||
3. **If the user invoked a sub-command (e.g. `craft`, `shape`, `audit`), load its reference file too.** This is non-negotiable: `craft` without `craft.md` loaded means you'll skip the shape-and-confirm step the user expects.
|
||||
|
||||
Skipping these produces generic output that ignores the project.
|
||||
|
||||
### 1. Context gathering
|
||||
|
||||
Two files, case-insensitive. The loader looks at the project root by default and falls back to `.agents/context/` and `docs/` if the root is clean. Override with `IMPECCABLE_CONTEXT_DIR=path/to/dir` (absolute or relative to cwd).
|
||||
|
||||
- **PRODUCT.md**: required. Users, brand, tone, anti-references, strategic principles.
|
||||
- **DESIGN.md**: optional, strongly recommended. Colors, typography, elevation, components.
|
||||
|
||||
Load both in one call:
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/load-context.mjs
|
||||
```
|
||||
|
||||
Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`. The output's `contextDir` field tells you where the files were resolved from.
|
||||
|
||||
If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `{{command_prefix}}impeccable teach` or `{{command_prefix}}impeccable document` (they rewrite the files), or the user manually edited one.
|
||||
|
||||
`{{command_prefix}}impeccable live` already warms context via `live.mjs`. If you've run `live.mjs`, don't also run `load-context.mjs` this session.
|
||||
|
||||
If PRODUCT.md is missing, empty, or placeholder (`[TODO]` markers, <200 chars): run `{{command_prefix}}impeccable teach`, then resume the user's original task with the fresh context. If the original task was `{{command_prefix}}impeccable craft`, resume into `{{command_prefix}}impeccable shape` before any implementation work.
|
||||
|
||||
If DESIGN.md is missing: nudge once per session (*"Run `{{command_prefix}}impeccable document` for more on-brand output"*), then proceed.
|
||||
|
||||
### 2. Register
|
||||
|
||||
Every design task is **brand** (marketing, landing, campaign, long-form content, portfolio: design IS the product) or **product** (app UI, admin, dashboard, tool: design SERVES the product).
|
||||
|
||||
Identify before designing. Priority: (1) cue in the task itself ("landing page" vs "dashboard"); (2) the surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. First match wins.
|
||||
|
||||
If PRODUCT.md lacks the `register` field (legacy), infer it once from its "Users" and "Product Purpose" sections, then cache the inferred value for the session. Suggest the user run `{{command_prefix}}impeccable teach` to add the field explicitly.
|
||||
|
||||
Load the matching reference: [reference/brand.md](reference/brand.md) or [reference/product.md](reference/product.md). The shared design laws below apply to both.
|
||||
|
||||
## Shared design laws
|
||||
|
||||
Apply to every design, both registers. Match implementation complexity to the aesthetic vision: maximalism needs elaborate code, minimalism needs precision. Interpret creatively. Vary across projects; never converge on the same choices. {{model}} is capable of extraordinary work. Don't hold back.
|
||||
|
||||
### Color
|
||||
|
||||
- Use OKLCH. Reduce chroma as lightness approaches 0 or 100; high chroma at extremes looks garish.
|
||||
- Never use `#000` or `#fff`. Tint every neutral toward the brand hue (chroma 0.005–0.01 is enough).
|
||||
- Pick a **color strategy** before picking colors. Four steps on the commitment axis:
|
||||
- **Restrained**: tinted neutrals + one accent ≤10%. Product default; brand minimalism.
|
||||
- **Committed**: one saturated color carries 30–60% of the surface. Brand default for identity-driven pages.
|
||||
- **Full palette**: 3–4 named roles, each used deliberately. Brand campaigns; product data viz.
|
||||
- **Drenched**: the surface IS the color. Brand heroes, campaign pages.
|
||||
- The "one accent ≤10%" rule is Restrained only. Committed / Full palette / Drenched exceed it on purpose. Don't collapse every design to Restrained by reflex.
|
||||
|
||||
### Theme
|
||||
|
||||
Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe."
|
||||
|
||||
Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does.
|
||||
|
||||
"Observability dashboard" does not force an answer. "SRE glancing at incident severity on a 27-inch monitor at 2am in a dim room" does. Run the sentence, not the category.
|
||||
|
||||
### Typography
|
||||
|
||||
- Cap body line length at 65–75ch.
|
||||
- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
|
||||
|
||||
### Layout
|
||||
|
||||
- Vary spacing for rhythm. Same padding everywhere is monotony.
|
||||
- Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong.
|
||||
- Don't wrap everything in a container. Most things don't need one.
|
||||
|
||||
### Motion
|
||||
|
||||
- Don't animate CSS layout properties.
|
||||
- Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic.
|
||||
|
||||
### Absolute bans
|
||||
|
||||
Match-and-refuse. If you're about to write any of these, rewrite the element with different structure.
|
||||
|
||||
- **Side-stripe borders.** `border-left` or `border-right` greater than 1px as a colored accent on cards, list items, callouts, or alerts. Never intentional. Rewrite with full borders, background tints, leading numbers/icons, or nothing.
|
||||
- **Gradient text.** `background-clip: text` combined with a gradient background. Decorative, never meaningful. Use a single solid color. Emphasis via weight or size.
|
||||
- **Glassmorphism as default.** Blurs and glass cards used decoratively. Rare and purposeful, or nothing.
|
||||
- **The hero-metric template.** Big number, small label, supporting stats, gradient accent. SaaS cliché.
|
||||
- **Identical card grids.** Same-sized cards with icon + heading + text, repeated endlessly.
|
||||
- **Modal as first thought.** Modals are usually laziness. Exhaust inline / progressive alternatives first.
|
||||
|
||||
### Copy
|
||||
|
||||
- Every word earns its place. No restated headings, no intros that repeat the title.
|
||||
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
|
||||
|
||||
### The AI slop test
|
||||
|
||||
If someone could look at this interface and say "AI made that" without doubt, it's failed. Cross-register failures are the absolute bans above. Register-specific failures live in each reference.
|
||||
|
||||
**Category-reflex check.** Run at two altitudes; the second one catches what the first one misses.
|
||||
|
||||
- **First-order:** if someone could guess the theme + palette from the category alone ("observability → dark blue", "healthcare → white + teal", "finance → navy + gold", "crypto → neon on black"), it's the first training-data reflex. Rework the scene sentence and color strategy until the answer isn't obvious from the domain.
|
||||
- **Second-order:** if someone could guess the aesthetic family from category-plus-anti-references ("AI workflow tool that's not SaaS-cream → editorial-typographic", "fintech that's not navy-and-gold → terminal-native dark mode"), it's the trap one tier deeper. The first reflex was avoided; the second wasn't. Rework until both answers are not obvious. The brand register's [reflex-reject aesthetic lanes](reference/brand.md) list catches the currently-saturated families.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Category | Description | Reference |
|
||||
|---|---|---|---|
|
||||
| `craft [feature]` | Build | Shape, then build a feature end-to-end | [reference/craft.md](reference/craft.md) |
|
||||
| `shape [feature]` | Build | Plan UX/UI before writing code | [reference/shape.md](reference/shape.md) |
|
||||
| `teach` | Build | Set up PRODUCT.md and DESIGN.md context | [reference/teach.md](reference/teach.md) |
|
||||
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
|
||||
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
|
||||
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
|
||||
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
|
||||
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
|
||||
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
|
||||
| `distill [target]` | Refine | Strip to essence, remove complexity | [reference/distill.md](reference/distill.md) |
|
||||
| `harden [target]` | Refine | Production-ready: errors, i18n, edge cases | [reference/harden.md](reference/harden.md) |
|
||||
| `onboard [target]` | Refine | Design first-run flows, empty states, activation | [reference/onboard.md](reference/onboard.md) |
|
||||
| `animate [target]` | Enhance | Add purposeful animations and motion | [reference/animate.md](reference/animate.md) |
|
||||
| `colorize [target]` | Enhance | Add strategic color to monochromatic UIs | [reference/colorize.md](reference/colorize.md) |
|
||||
| `typeset [target]` | Enhance | Improve typography hierarchy and fonts | [reference/typeset.md](reference/typeset.md) |
|
||||
| `layout [target]` | Enhance | Fix spacing, rhythm, and visual hierarchy | [reference/layout.md](reference/layout.md) |
|
||||
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
|
||||
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
|
||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
|
||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||
|
||||
Plus two management commands: `pin <command>` and `unpin <command>`, detailed below.
|
||||
|
||||
### Routing rules
|
||||
|
||||
1. **No argument**: render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do.
|
||||
2. **First word matches a command**: load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match**: general design invocation. Apply the setup steps, shared design laws, and the loaded register reference, using the full argument as context.
|
||||
|
||||
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `{{command_prefix}}impeccable`.
|
||||
|
||||
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `teach` as a blocker, finish teach, refresh context, then resume the original command and target.
|
||||
|
||||
## Pin / Unpin
|
||||
|
||||
**Pin** creates a standalone shortcut so `{{command_prefix}}<command>` invokes `{{command_prefix}}impeccable <command>` directly. **Unpin** removes it. The script writes to every harness directory present in the project.
|
||||
|
||||
```bash
|
||||
node {{scripts_path}}/pin.mjs <pin|unpin> <command>
|
||||
```
|
||||
|
||||
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
|
||||
|
||||
156
.claude/skills/mailprotector/SKILL.md
Normal file
156
.claude/skills/mailprotector/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: mailprotector
|
||||
description: "Manage the ACG Mailprotector CloudFilter email-security gateway (emailservice.io). Search/release held/quarantined mail (in+outbound), pull mail-flow logs (why a message did/did not deliver), inspect + manage allow/block rules. Read-only default; releases/rule-changes gated --confirm. Triggers: mailprotector, cloudfilter, held/quarantined mail, release email, allow/block rule, INKY. Live production."
|
||||
|
||||
---
|
||||
|
||||
# Mailprotector / CloudFilter Skill
|
||||
|
||||
Standalone CLI client for the **Mailprotector CloudFilter REST API**
|
||||
(`emailservice.io`), the reseller email-security platform ACG layers on top of
|
||||
client mail flow. Read-only by default; every write (release, rule add, config
|
||||
change) is gated behind `--confirm`.
|
||||
|
||||
## The two-layer context (important)
|
||||
|
||||
ACG's email security sits in front of client mailboxes as two cooperating layers:
|
||||
|
||||
| Layer | What it does |
|
||||
|---|---|
|
||||
| **Mailprotector CloudFilter** | The delivery / filtering gateway. Inbound and outbound mail passes through it; spam, virus, and policy hits are **held / quarantined** here. Releasing a held message re-injects it for delivery. This is the API this skill drives. |
|
||||
| **INKY** | Email annotation / phishing-banner layer. Adds the warning banners and protects against impersonation. Not part of this API surface. |
|
||||
|
||||
Both sit **layered on top of the client's own Exchange / M365 mail flow** — so a
|
||||
"missing email" investigation usually means: was it held at CloudFilter (check
|
||||
`messages` / `logs`), or did it pass CloudFilter and stall in Exchange?
|
||||
|
||||
## Connection
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Base URL | `https://emailservice.io/api/v1` (override `MAILPROTECTOR_API_BASE_URL`) |
|
||||
| Auth | `Authorization: Bearer <api_key>` |
|
||||
| Vault entry | `msp-tools/mailprotector.sops.yaml`, field `credentials.api_key` |
|
||||
| Env override | `MAILPROTECTOR_API_KEY` |
|
||||
|
||||
Credential resolution order: `MAILPROTECTOR_API_KEY` env -> vault
|
||||
`credentials.api_key`. The key is never hardcoded; a clear setup error is raised
|
||||
if neither resolves.
|
||||
|
||||
### Scopes
|
||||
|
||||
Five entity types carry `logs` / `messages` / `configuration` /
|
||||
`allow_block_rules` / `users` / `domains` sub-resources. Path form is
|
||||
`/{scope}/{id}/...`:
|
||||
|
||||
```
|
||||
resellers, customers, domains, user_groups, users
|
||||
```
|
||||
|
||||
The CLI validates `scope` against this set.
|
||||
|
||||
## Running the CLI
|
||||
|
||||
This machine's Python launcher is `py` (per identity.json); `python` / `python3`
|
||||
also work. Run from the scripts dir so the two modules resolve.
|
||||
|
||||
```bash
|
||||
cd C:/claudetools/.claude/skills/mailprotector/scripts
|
||||
|
||||
py mp.py status # validate token (GET /domains, per_page=1)
|
||||
py mp.py domains # list domains (global)
|
||||
py mp.py domains --scope customers --id <id>
|
||||
py mp.py domain <domain_id>
|
||||
py mp.py customers <reseller_id>
|
||||
py mp.py customer <customer_id>
|
||||
py mp.py users <scope> <id>
|
||||
py mp.py user <user_id>
|
||||
py mp.py find-user user@client.com # locate a user / alias by email (a READ)
|
||||
py mp.py config <scope> <id> # shows permissions.messages.allow_spam_release
|
||||
py mp.py rules <scope> <id>
|
||||
```
|
||||
|
||||
### Mail-flow logs and held mail (the common investigation)
|
||||
|
||||
Both accept the same filters: `--sender --recipient --subject --decision
|
||||
--sort-field --sort-direction --page --page-size`.
|
||||
|
||||
```bash
|
||||
# Why didn't this arrive? Look at the decision in the flow logs.
|
||||
py mp.py logs domains <domain_id> --recipient ceo@client.com --decision quarantine_spam
|
||||
|
||||
# Held / quarantined mail search.
|
||||
py mp.py messages domains <domain_id> --sender boss@vendor.com
|
||||
```
|
||||
|
||||
`--decision` values: `default`, `deliver`, `quarantine_spam`,
|
||||
`quarantine_virus`, `quarantine_policy`, `bounce`, `encrypt`, `delete`.
|
||||
`--sort-field` values: `@timestamp` (default), `prime.direction`,
|
||||
`prime.from_header_raw`, `prime.recipient`, `prime.subject`, `prime.decision`,
|
||||
`prime.score`.
|
||||
|
||||
## Writes (gated)
|
||||
|
||||
Every mutating command prints a `[DRY RUN]` line and exits non-zero unless you
|
||||
pass `--confirm`.
|
||||
|
||||
```bash
|
||||
py mp.py release <message_id> --confirm
|
||||
py mp.py release <message_id> --recipients alt@client.com --confirm
|
||||
py mp.py release-many <scope> <id> --ids 111,222,333 --confirm
|
||||
py mp.py release-many <scope> <id> --all --confirm
|
||||
py mp.py add-rule <scope> <id> --value vendor.com --type allow --confirm
|
||||
py mp.py enable-release <scope> <id> --confirm
|
||||
```
|
||||
|
||||
## The `allow_spam_release` gotcha
|
||||
|
||||
Releasing a held **spam** message fails if the owning entity does not have
|
||||
`permissions.messages.allow_spam_release = true`. Workflow:
|
||||
|
||||
1. `py mp.py config <scope> <id>` — check `allow_spam_release`.
|
||||
2. If `false`: `py mp.py enable-release <scope> <id> --confirm`.
|
||||
3. Re-run the `release` / `release-many`.
|
||||
|
||||
Virus and policy quarantines are governed separately — only spam release is
|
||||
gated by this permission.
|
||||
|
||||
## Example workflow: find a client's held outbound mail from a sender and release it
|
||||
|
||||
```bash
|
||||
# 1. Find the client's domain.
|
||||
py mp.py domains --scope customers --id <customer_id>
|
||||
|
||||
# 2. Search held messages from the sender (outbound = sender is the client user).
|
||||
py mp.py messages domains <domain_id> --sender user@client.com --decision quarantine_spam
|
||||
|
||||
# 3. If it's spam-held, make sure release is permitted on the domain.
|
||||
py mp.py config domains <domain_id> # check allow_spam_release
|
||||
py mp.py enable-release domains <domain_id> --confirm # only if needed
|
||||
|
||||
# 4. Release by message id (DRY RUN first — omit --confirm to preview).
|
||||
py mp.py release <message_id> # [DRY RUN]
|
||||
py mp.py release <message_id> --confirm # actually release
|
||||
```
|
||||
|
||||
## Raw escape hatch
|
||||
|
||||
The named commands cover the common surface; for anything else, hit the path
|
||||
directly. Non-GET methods still require `--confirm`.
|
||||
|
||||
```bash
|
||||
py mp.py raw GET domains/<id>/logs
|
||||
py mp.py raw POST messages/<id>/deliver --body '{"include_original_recipients":1}' --confirm
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This is the **LIVE production reseller CloudFilter platform**. A release
|
||||
re-delivers real mail to real recipients, and an allow rule can let real spam
|
||||
or phishing through — confirm the target entity with a read command before any
|
||||
write, and prefer releasing specific message ids over `--all`.
|
||||
- Pagination: `page` (default 1) and `per_page` (default 25); reseller
|
||||
`messages` caps `per_page` at 50. The `X-Pagination` response header carries
|
||||
the page/total metadata.
|
||||
- Full endpoint catalog, filter tables, and the global `field[op]=value`
|
||||
operators live in `references/api.md`.
|
||||
155
.claude/skills/mailprotector/references/api.md
Normal file
155
.claude/skills/mailprotector/references/api.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Mailprotector CloudFilter REST API — Reference
|
||||
|
||||
Full endpoint catalog and filter tables for the `mailprotector` skill. SKILL.md
|
||||
stays lean; the detail lives here.
|
||||
|
||||
## Connection
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Base URL | `https://emailservice.io/api/v1` |
|
||||
| Override env | `MAILPROTECTOR_API_BASE_URL` |
|
||||
| Auth | `Authorization: Bearer <api_key>` |
|
||||
| Key env override | `MAILPROTECTOR_API_KEY` |
|
||||
| Vault entry | `msp-tools/mailprotector.sops.yaml`, field `credentials.api_key` |
|
||||
|
||||
Credential resolution order: `MAILPROTECTOR_API_KEY` env -> vault
|
||||
`credentials.api_key` (read via `bash <root>/.claude/scripts/vault.sh get-field`).
|
||||
A clear setup error is raised if neither resolves.
|
||||
|
||||
## Scopes
|
||||
|
||||
The five entity types that carry `logs`, `messages`, `configuration`,
|
||||
`users`, `domains`, and `allow_block_rules` sub-resources. Path form is
|
||||
`/{scope}/{id}/...`:
|
||||
|
||||
```
|
||||
resellers, customers, domains, user_groups, users
|
||||
```
|
||||
|
||||
The CLI validates `scope` against this set.
|
||||
|
||||
## Pagination
|
||||
|
||||
| Param | Default | Notes |
|
||||
|---|---|---|
|
||||
| `page` | 1 | 1-indexed page number |
|
||||
| `per_page` | 25 | Max **50** on reseller `messages` |
|
||||
|
||||
The response includes an `X-Pagination` response header (a JSON document with
|
||||
the page/total metadata).
|
||||
|
||||
## Global list filtering
|
||||
|
||||
List endpoints accept `field[op]=value` filters. Operators:
|
||||
|
||||
| Op | Meaning |
|
||||
|---|---|
|
||||
| `Gt` | greater than |
|
||||
| `Geq` | greater than or equal |
|
||||
| `Lt` | less than |
|
||||
| `Leq` | less than or equal |
|
||||
| `Eq` | equal |
|
||||
|
||||
Example: `created_at[geq]=2026-06-01`.
|
||||
|
||||
## Logs / messages filtering
|
||||
|
||||
Every `.../logs` and `.../messages` endpoint accepts these params:
|
||||
|
||||
| Param | Default | Allowed values |
|
||||
|---|---|---|
|
||||
| `sort_direction` | `desc` | `desc`, `asc` |
|
||||
| `sort_field` | `@timestamp` | `@timestamp`, `prime.direction`, `prime.from_header_raw`, `prime.recipient`, `prime.subject`, `prime.decision`, `prime.score` |
|
||||
| `page` | 1 | integer |
|
||||
| `page_size` | (API default) | integer |
|
||||
| `sender` | (none) | sender filter |
|
||||
| `recipient` | (none) | recipient filter |
|
||||
| `subject` | (none) | subject filter |
|
||||
| `decision` | `all` | `default`, `deliver`, `quarantine_spam`, `quarantine_virus`, `quarantine_policy`, `bounce`, `encrypt`, `delete` |
|
||||
|
||||
## READ endpoints
|
||||
|
||||
| Method | Path | Client method | CLI |
|
||||
|---|---|---|---|
|
||||
| GET | `/domains` | `domains()` | `domains` |
|
||||
| GET | `/{scope}/{id}/domains` | `domains(scope,id)` | `domains --scope --id` |
|
||||
| GET | `/domains/{id}` | `domain(id)` | `domain <id>` |
|
||||
| GET | `/resellers/{id}/customers` | `customers(id)` | `customers <reseller_id>` |
|
||||
| GET | `/customers/{id}` | `customer(id)` | `customer <id>` |
|
||||
| GET | `/{scope}/{id}/users` | `users(scope,id)` | `users <scope> <id>` |
|
||||
| GET | `/users/{id}` | `user(id)` | `user <id>` |
|
||||
| POST | `/users/find_by_address` | `find_user(address)` | `find-user <address>` |
|
||||
| GET | `/{scope}/{id}/logs` | `logs(scope,id,...)` | `logs <scope> <id>` |
|
||||
| GET | `/{scope}/{id}/messages` | `messages(scope,id,...)` | `messages <scope> <id>` |
|
||||
| GET | `/{scope}/{id}/configuration` | `configuration(scope,id)` | `config <scope> <id>` |
|
||||
| GET | `/{scope}/{id}/allow_block_rules` | `allow_block_rules(scope,id)` | `rules <scope> <id>` |
|
||||
|
||||
**`find_by_address` is a READ** despite being a POST — it looks up a user / alias
|
||||
by email. It is NOT gated behind `--confirm`.
|
||||
|
||||
`status` is a synthetic read: `GET /domains?per_page=1` used purely to validate
|
||||
the bearer token (HTTP 200 = key good).
|
||||
|
||||
## WRITE endpoints (gated behind `--confirm`)
|
||||
|
||||
Without `--confirm` the CLI prints `[DRY RUN] Would <action>: <detail>` and exits
|
||||
with code 2. With `--confirm` it performs the call.
|
||||
|
||||
### Release one held message
|
||||
|
||||
```
|
||||
POST /messages/{message_id}/deliver
|
||||
body: {"include_original_recipients": 1, "recipients": "<optional csv>"}
|
||||
```
|
||||
Client: `release_message(message_id, recipients=None)` — CLI: `release <message_id> [--recipients csv] --confirm`
|
||||
|
||||
### Bulk release held messages
|
||||
|
||||
```
|
||||
POST /{scope}/{id}/messages/deliver_many
|
||||
body: {"include_original_recipients": 1, "recipients": "<optional>",
|
||||
"all_selected": false, "ids": "<csv ids>"}
|
||||
```
|
||||
Client: `release_many(scope, id, ids=None, all_selected=False, recipients=None)`
|
||||
CLI: `release-many <scope> <id> [--ids csv | --all] [--recipients csv] --confirm`
|
||||
|
||||
### Add allow / block rule
|
||||
|
||||
```
|
||||
POST /{scope}/{id}/allow_block_rules
|
||||
body: {"value": "...", "rule_type": "allow" | "block"}
|
||||
```
|
||||
Client: `add_rule(scope, id, value, rule_type)` — CLI: `add-rule <scope> <id> --value <v> --type allow|block --confirm`
|
||||
|
||||
### Enable spam release on an entity
|
||||
|
||||
```
|
||||
PUT /{scope}/{id}/configuration
|
||||
body: {"permissions": {"messages": {"allow_spam_release": true}}}
|
||||
```
|
||||
Client: `enable_release(scope, id)` — CLI: `enable-release <scope> <id> --confirm`
|
||||
|
||||
This is required before an entity's held **spam** can be released. Check the
|
||||
state first with `config <scope> <id>` and look at
|
||||
`permissions.messages.allow_spam_release`.
|
||||
|
||||
## Raw escape hatch
|
||||
|
||||
```
|
||||
py mp.py raw <METHOD> <path> [--body JSON] [--confirm]
|
||||
```
|
||||
Non-GET methods require `--confirm`. Use for any endpoint not wrapped by a named
|
||||
command.
|
||||
|
||||
## The `allow_spam_release` gotcha
|
||||
|
||||
Releasing a held **spam** message will fail (or silently no-op) if the owning
|
||||
entity does not have `permissions.messages.allow_spam_release = true`. The fix:
|
||||
|
||||
1. `py mp.py config <scope> <id>` — confirm `allow_spam_release` is `false`.
|
||||
2. `py mp.py enable-release <scope> <id> --confirm` — flip it to `true`.
|
||||
3. Re-run the `release` / `release-many`.
|
||||
|
||||
Virus and policy quarantines are governed separately — only spam release is
|
||||
gated by this permission.
|
||||
322
.claude/skills/mailprotector/scripts/mp.py
Normal file
322
.claude/skills/mailprotector/scripts/mp.py
Normal file
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for the mailprotector skill — Mailprotector CloudFilter REST API.
|
||||
|
||||
Read subcommands run freely. Write subcommands (release, release-many, add-rule,
|
||||
enable-release, raw with a non-GET method) refuse to run unless --confirm is
|
||||
passed; without it they print what they WOULD do and exit non-zero.
|
||||
|
||||
NOTE: find-user is a READ even though it is a POST under the hood — it is NOT
|
||||
gated.
|
||||
|
||||
Read examples:
|
||||
py mp.py status
|
||||
py mp.py domains
|
||||
py mp.py domain <domain_id>
|
||||
py mp.py customers <reseller_id>
|
||||
py mp.py users <scope> <id>
|
||||
py mp.py find-user user@client.com
|
||||
py mp.py logs <scope> <id> --sender boss@vendor.com --decision quarantine_spam
|
||||
py mp.py messages <scope> <id> --recipient ceo@client.com
|
||||
py mp.py config <scope> <id>
|
||||
py mp.py rules <scope> <id>
|
||||
|
||||
Write examples (all require --confirm):
|
||||
py mp.py release <message_id> --confirm
|
||||
py mp.py release-many <scope> <id> --ids 111,222,333 --confirm
|
||||
py mp.py add-rule <scope> <id> --value vendor.com --type allow --confirm
|
||||
py mp.py enable-release <scope> <id> --confirm
|
||||
|
||||
Escape hatch (raw request against any path; non-GET requires --confirm):
|
||||
py mp.py raw GET domains/123/logs
|
||||
py mp.py raw POST messages/999/deliver --body '{...}' --confirm
|
||||
|
||||
`scope` values are validated against:
|
||||
resellers, customers, domains, user_groups, users
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from mp_client import MailprotectorClient, MailprotectorError, VALID_SCOPES
|
||||
|
||||
|
||||
def _emit(obj) -> None:
|
||||
print(json.dumps(obj, indent=2, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def _parse_body(raw: str | None) -> dict | None:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SystemExit(f"--body is not valid JSON: {exc}")
|
||||
if not isinstance(parsed, dict):
|
||||
raise SystemExit("--body must be a JSON object")
|
||||
return parsed
|
||||
|
||||
|
||||
def _require_confirm(args, action: str, detail: str) -> None:
|
||||
if not getattr(args, "confirm", False):
|
||||
print(f"[DRY RUN] Would {action}: {detail}")
|
||||
print("Refusing to perform a write without --confirm. Re-run with --confirm.")
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def _add_log_filters(sp) -> None:
|
||||
"""Attach the shared logs/messages filter flags to a subparser."""
|
||||
sp.add_argument("scope", choices=VALID_SCOPES)
|
||||
sp.add_argument("id")
|
||||
sp.add_argument("--sender")
|
||||
sp.add_argument("--recipient")
|
||||
sp.add_argument("--subject")
|
||||
sp.add_argument(
|
||||
"--decision",
|
||||
choices=[
|
||||
"default",
|
||||
"deliver",
|
||||
"quarantine_spam",
|
||||
"quarantine_virus",
|
||||
"quarantine_policy",
|
||||
"bounce",
|
||||
"encrypt",
|
||||
"delete",
|
||||
],
|
||||
)
|
||||
sp.add_argument(
|
||||
"--sort-field",
|
||||
dest="sort_field",
|
||||
choices=[
|
||||
"@timestamp",
|
||||
"prime.direction",
|
||||
"prime.from_header_raw",
|
||||
"prime.recipient",
|
||||
"prime.subject",
|
||||
"prime.decision",
|
||||
"prime.score",
|
||||
],
|
||||
)
|
||||
sp.add_argument(
|
||||
"--sort-direction", dest="sort_direction", choices=["desc", "asc"]
|
||||
)
|
||||
sp.add_argument("--page", type=int)
|
||||
sp.add_argument("--page-size", dest="page_size", type=int)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="mp.py", description="Mailprotector CloudFilter REST API CLI"
|
||||
)
|
||||
p.add_argument("--json", action="store_true", help="emit raw JSON (default)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
# --- read ---
|
||||
sub.add_parser("status", help="validate token (GET /domains per_page=1)")
|
||||
|
||||
sp = sub.add_parser("domains", help="list domains (global or scoped)")
|
||||
sp.add_argument("--scope", choices=VALID_SCOPES)
|
||||
sp.add_argument("--id", help="entity id (required if --scope given)")
|
||||
sp.add_argument("--page", type=int, default=1)
|
||||
sp.add_argument("--per-page", dest="per_page", type=int, default=25)
|
||||
|
||||
sp = sub.add_parser("domain", help="one domain")
|
||||
sp.add_argument("domain_id")
|
||||
|
||||
sp = sub.add_parser("customers", help="customers under a reseller")
|
||||
sp.add_argument("reseller_id")
|
||||
sp.add_argument("--page", type=int, default=1)
|
||||
sp.add_argument("--per-page", dest="per_page", type=int, default=25)
|
||||
|
||||
sp = sub.add_parser("customer", help="one customer")
|
||||
sp.add_argument("customer_id")
|
||||
|
||||
sp = sub.add_parser("users", help="users under an entity")
|
||||
sp.add_argument("scope", choices=VALID_SCOPES)
|
||||
sp.add_argument("id")
|
||||
sp.add_argument("--page", type=int, default=1)
|
||||
sp.add_argument("--per-page", dest="per_page", type=int, default=25)
|
||||
|
||||
sp = sub.add_parser("user", help="one user")
|
||||
sp.add_argument("user_id")
|
||||
|
||||
sp = sub.add_parser("find-user", help="find a user/alias by email address")
|
||||
sp.add_argument("address")
|
||||
|
||||
sp = sub.add_parser("logs", help="mail-flow logs for an entity")
|
||||
_add_log_filters(sp)
|
||||
|
||||
sp = sub.add_parser("messages", help="held/quarantined messages for an entity")
|
||||
_add_log_filters(sp)
|
||||
|
||||
sp = sub.add_parser("config", help="entity configuration")
|
||||
sp.add_argument("scope", choices=VALID_SCOPES)
|
||||
sp.add_argument("id")
|
||||
|
||||
sp = sub.add_parser("rules", help="allow/block rules for an entity")
|
||||
sp.add_argument("scope", choices=VALID_SCOPES)
|
||||
sp.add_argument("id")
|
||||
|
||||
# --- write (gated) ---
|
||||
sp = sub.add_parser("release", help="release one held message")
|
||||
sp.add_argument("message_id")
|
||||
sp.add_argument("--recipients", help="optional csv of override recipients")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("release-many", help="bulk-release held messages")
|
||||
sp.add_argument("scope", choices=VALID_SCOPES)
|
||||
sp.add_argument("id")
|
||||
sp.add_argument("--ids", help="csv of message ids to release")
|
||||
sp.add_argument("--all", action="store_true", help="release all selected")
|
||||
sp.add_argument("--recipients", help="optional csv of override recipients")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("add-rule", help="add an allow/block rule")
|
||||
sp.add_argument("scope", choices=VALID_SCOPES)
|
||||
sp.add_argument("id")
|
||||
sp.add_argument("--value", required=True)
|
||||
sp.add_argument("--type", dest="rule_type", required=True, choices=["allow", "block"])
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser(
|
||||
"enable-release", help="enable spam release on an entity (allow_spam_release)"
|
||||
)
|
||||
sp.add_argument("scope", choices=VALID_SCOPES)
|
||||
sp.add_argument("id")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
# --- raw escape hatch ---
|
||||
sp = sub.add_parser("raw", help="raw request against any path")
|
||||
sp.add_argument("method", choices=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
||||
sp.add_argument("path", help="relative path, e.g. domains/123/logs")
|
||||
sp.add_argument("--body")
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
args = p.parse_args(argv)
|
||||
client = MailprotectorClient()
|
||||
|
||||
try:
|
||||
if args.cmd == "status":
|
||||
result = client.status()
|
||||
_emit({"status": "ok", "auth": "valid", "sample": result})
|
||||
elif args.cmd == "domains":
|
||||
if args.scope and not args.id:
|
||||
raise SystemExit("--id is required when --scope is given")
|
||||
_emit(
|
||||
client.domains(
|
||||
scope=args.scope,
|
||||
entity_id=args.id,
|
||||
page=args.page,
|
||||
per_page=args.per_page,
|
||||
)
|
||||
)
|
||||
elif args.cmd == "domain":
|
||||
_emit(client.domain(args.domain_id))
|
||||
elif args.cmd == "customers":
|
||||
_emit(
|
||||
client.customers(
|
||||
args.reseller_id, page=args.page, per_page=args.per_page
|
||||
)
|
||||
)
|
||||
elif args.cmd == "customer":
|
||||
_emit(client.customer(args.customer_id))
|
||||
elif args.cmd == "users":
|
||||
_emit(
|
||||
client.users(
|
||||
args.scope, args.id, page=args.page, per_page=args.per_page
|
||||
)
|
||||
)
|
||||
elif args.cmd == "user":
|
||||
_emit(client.user(args.user_id))
|
||||
elif args.cmd == "find-user":
|
||||
_emit(client.find_user(args.address))
|
||||
elif args.cmd == "logs":
|
||||
_emit(
|
||||
client.logs(
|
||||
args.scope,
|
||||
args.id,
|
||||
sort_direction=args.sort_direction,
|
||||
sort_field=args.sort_field,
|
||||
page=args.page,
|
||||
page_size=args.page_size,
|
||||
sender=args.sender,
|
||||
recipient=args.recipient,
|
||||
subject=args.subject,
|
||||
decision=args.decision,
|
||||
)
|
||||
)
|
||||
elif args.cmd == "messages":
|
||||
_emit(
|
||||
client.messages(
|
||||
args.scope,
|
||||
args.id,
|
||||
sort_direction=args.sort_direction,
|
||||
sort_field=args.sort_field,
|
||||
page=args.page,
|
||||
page_size=args.page_size,
|
||||
sender=args.sender,
|
||||
recipient=args.recipient,
|
||||
subject=args.subject,
|
||||
decision=args.decision,
|
||||
)
|
||||
)
|
||||
elif args.cmd == "config":
|
||||
_emit(client.configuration(args.scope, args.id))
|
||||
elif args.cmd == "rules":
|
||||
_emit(client.allow_block_rules(args.scope, args.id))
|
||||
|
||||
elif args.cmd == "release":
|
||||
detail = args.message_id
|
||||
if args.recipients:
|
||||
detail += f" -> {args.recipients}"
|
||||
_require_confirm(args, "RELEASE held message", detail)
|
||||
_emit(client.release_message(args.message_id, recipients=args.recipients))
|
||||
elif args.cmd == "release-many":
|
||||
if not args.ids and not args.all:
|
||||
raise SystemExit("release-many requires --ids <csv> or --all")
|
||||
target = "ALL selected" if args.all else f"ids={args.ids}"
|
||||
_require_confirm(
|
||||
args, "BULK RELEASE held messages", f"{args.scope}/{args.id}: {target}"
|
||||
)
|
||||
_emit(
|
||||
client.release_many(
|
||||
args.scope,
|
||||
args.id,
|
||||
ids=args.ids,
|
||||
all_selected=args.all,
|
||||
recipients=args.recipients,
|
||||
)
|
||||
)
|
||||
elif args.cmd == "add-rule":
|
||||
_require_confirm(
|
||||
args,
|
||||
f"add {args.rule_type} rule",
|
||||
f"{args.scope}/{args.id}: {args.value}",
|
||||
)
|
||||
_emit(
|
||||
client.add_rule(args.scope, args.id, args.value, args.rule_type)
|
||||
)
|
||||
elif args.cmd == "enable-release":
|
||||
_require_confirm(
|
||||
args,
|
||||
"enable spam release (allow_spam_release=true)",
|
||||
f"{args.scope}/{args.id}",
|
||||
)
|
||||
_emit(client.enable_release(args.scope, args.id))
|
||||
|
||||
elif args.cmd == "raw":
|
||||
body = _parse_body(args.body)
|
||||
if args.method != "GET":
|
||||
_require_confirm(args, f"{args.method} {args.path}", json.dumps(body))
|
||||
_emit(client.request(args.method, args.path, json_body=body))
|
||||
else:
|
||||
p.error(f"unknown command {args.cmd}")
|
||||
except MailprotectorError as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
443
.claude/skills/mailprotector/scripts/mp_client.py
Normal file
443
.claude/skills/mailprotector/scripts/mp_client.py
Normal file
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Client for the mailprotector skill — Mailprotector CloudFilter REST API.
|
||||
|
||||
Talks to the live Mailprotector CloudFilter platform at emailservice.io. This is
|
||||
the reseller email-security gateway (CloudFilter delivery + INKY annotation) that
|
||||
ACG layers on top of client Exchange mail flow. Held / quarantined mail, mail-flow
|
||||
logs, allow/block rules, and message release all live behind this API.
|
||||
|
||||
Auth: Bearer token. The API key is used directly as the bearer token:
|
||||
Authorization: Bearer <api_key>
|
||||
|
||||
Credentials are NEVER hardcoded. They are loaded at runtime from the SOPS vault
|
||||
entry `msp-tools/mailprotector.sops.yaml`, or from an environment override.
|
||||
|
||||
Resolution order:
|
||||
1. MAILPROTECTOR_API_KEY env
|
||||
2. vault credentials.api_key (read via bash <root>/.claude/scripts/vault.sh)
|
||||
|
||||
Transport: prefers httpx if installed, else falls back to stdlib urllib so the
|
||||
skill works on a bare Python install.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
try:
|
||||
import httpx # type: ignore
|
||||
|
||||
_HAS_HTTPX = True
|
||||
except ImportError: # pragma: no cover
|
||||
_HAS_HTTPX = False
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent # .../.claude/skills/mailprotector
|
||||
ERROR_BODY_MAX_CHARS = 1500
|
||||
DEFAULT_TIMEOUT = 60.0
|
||||
DEFAULT_CONNECT_TIMEOUT = 15.0
|
||||
|
||||
API_BASE_URL = os.environ.get(
|
||||
"MAILPROTECTOR_API_BASE_URL", "https://emailservice.io/api/v1"
|
||||
)
|
||||
|
||||
VAULT_ENTRY = "msp-tools/mailprotector.sops.yaml"
|
||||
|
||||
# The five entity types that have logs / messages / configuration sub-resources.
|
||||
VALID_SCOPES = ("resellers", "customers", "domains", "user_groups", "users")
|
||||
|
||||
|
||||
class MailprotectorError(Exception):
|
||||
"""Any failure talking to the Mailprotector API or loading credentials."""
|
||||
|
||||
|
||||
# --- repo-root + credential loading -------------------------------------------
|
||||
def _resolve_claudetools_root() -> Path:
|
||||
"""Resolve the ClaudeTools repo root: env var, then identity.json, then derived.
|
||||
|
||||
Final fallback is derived from this file's location so it works on the
|
||||
Mac/Linux fleet, not only the Windows default.
|
||||
"""
|
||||
# SKILL_DIR = .../.claude/skills/mailprotector ; root is three levels up.
|
||||
derived_root = SKILL_DIR.parent.parent.parent
|
||||
|
||||
env_root = os.environ.get("CLAUDETOOLS_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root)
|
||||
|
||||
identity_path = derived_root / ".claude" / "identity.json"
|
||||
if identity_path.exists():
|
||||
try:
|
||||
data = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
root = data.get("claudetools_root")
|
||||
if root:
|
||||
return Path(root)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
return derived_root
|
||||
|
||||
|
||||
def _vault_field(field: str) -> Optional[str]:
|
||||
"""Read a single field from the mailprotector vault entry. None if absent.
|
||||
|
||||
Soft failure: a missing field (vault exits non-zero) returns None so the
|
||||
caller can surface a clean setup error. A missing vault wrapper or bash
|
||||
raises, since that is an environment problem the user must fix.
|
||||
"""
|
||||
root = _resolve_claudetools_root()
|
||||
vault_script = root / ".claude" / "scripts" / "vault.sh"
|
||||
if not vault_script.exists():
|
||||
raise MailprotectorError(
|
||||
f"vault wrapper not found at {vault_script}; set MAILPROTECTOR_API_KEY "
|
||||
"instead."
|
||||
)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["bash", str(vault_script), "get-field", VAULT_ENTRY, field],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise MailprotectorError(
|
||||
"'bash' not found on PATH. Install Git Bash or set MAILPROTECTOR_API_KEY."
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise MailprotectorError("vault call timed out.") from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
value = completed.stdout.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def load_api_key() -> str:
|
||||
"""Resolve the Mailprotector API key (bearer token).
|
||||
|
||||
Resolution order:
|
||||
1. MAILPROTECTOR_API_KEY env
|
||||
2. vault credentials.api_key
|
||||
|
||||
Raises MailprotectorError with setup guidance if nothing resolves.
|
||||
"""
|
||||
env_key = os.environ.get("MAILPROTECTOR_API_KEY")
|
||||
if env_key:
|
||||
return env_key.strip()
|
||||
|
||||
api_key = _vault_field("credentials.api_key")
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
raise MailprotectorError(
|
||||
"No Mailprotector / CloudFilter credentials found.\n"
|
||||
f" Expected vault entry: {VAULT_ENTRY} with:\n"
|
||||
" credentials.api_key (Bearer token for emailservice.io)\n"
|
||||
" Or set the MAILPROTECTOR_API_KEY environment variable for testing.\n"
|
||||
" Provision a reseller API key in the Mailprotector CloudFilter portal,\n"
|
||||
" then store it in the SOPS vault.\n"
|
||||
" See .claude/skills/mailprotector/SKILL.md for the full setup steps."
|
||||
)
|
||||
|
||||
|
||||
def validate_scope(scope: str) -> str:
|
||||
"""Ensure a scope is one of the five valid entity types. Raises otherwise."""
|
||||
if scope not in VALID_SCOPES:
|
||||
raise MailprotectorError(
|
||||
f"Invalid scope '{scope}'. Must be one of: {', '.join(VALID_SCOPES)}"
|
||||
)
|
||||
return scope
|
||||
|
||||
|
||||
# --- client -------------------------------------------------------------------
|
||||
class MailprotectorClient:
|
||||
def __init__(
|
||||
self,
|
||||
api_base_url: str = API_BASE_URL,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
||||
):
|
||||
self.api_base_url = api_base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.connect_timeout = connect_timeout
|
||||
self._api_key: Optional[str] = None
|
||||
|
||||
# -- auth ------------------------------------------------------------------
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
if self._api_key is None:
|
||||
self._api_key = load_api_key()
|
||||
return self._api_key
|
||||
|
||||
# -- core transport --------------------------------------------------------
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
) -> Any:
|
||||
"""One REST call against the API base. `path` is relative (e.g. 'domains')."""
|
||||
url = f"{self.api_base_url}/{path.lstrip('/')}"
|
||||
if params:
|
||||
# Drop None-valued params so optional filters stay off the query string.
|
||||
clean = {k: v for k, v in params.items() if v is not None}
|
||||
if clean:
|
||||
url = f"{url}?{urllib.parse.urlencode(clean, doseq=True)}"
|
||||
data = json.dumps(json_body).encode("utf-8") if json_body is not None else None
|
||||
headers = {"Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return self._http(
|
||||
method, url, data=data, headers=headers,
|
||||
auth_header=f"Bearer {self.api_key}",
|
||||
)
|
||||
|
||||
def _http(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[dict] = None,
|
||||
auth_header: Optional[str] = None,
|
||||
) -> Any:
|
||||
hdrs = dict(headers or {})
|
||||
if auth_header:
|
||||
hdrs["Authorization"] = auth_header
|
||||
|
||||
if _HAS_HTTPX:
|
||||
try:
|
||||
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.request(method, url, content=data, headers=hdrs)
|
||||
resp.raise_for_status()
|
||||
return self._parse(resp.content)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MailprotectorError(f"request timed out: {exc}") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS]
|
||||
raise MailprotectorError(
|
||||
f"HTTP {exc.response.status_code} {method} {url}: {detail}"
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise MailprotectorError(f"request failed: {exc}") from exc
|
||||
|
||||
# stdlib fallback
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
return self._parse(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
|
||||
raise MailprotectorError(f"HTTP {exc.code} {method} {url}: {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise MailprotectorError(f"request failed: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse(raw: bytes) -> Any:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
@staticmethod
|
||||
def _q(value: str) -> str:
|
||||
"""URL-quote a path segment (an id), keeping it safe in a path position."""
|
||||
return urllib.parse.quote(str(value), safe="")
|
||||
|
||||
# ======================================================================
|
||||
# READ METHODS (safe — always live)
|
||||
# ======================================================================
|
||||
def status(self) -> Any:
|
||||
"""Token validation probe: smallest possible authenticated GET."""
|
||||
return self.request("GET", "domains", params={"per_page": 1})
|
||||
|
||||
def domains(
|
||||
self,
|
||||
scope: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 25,
|
||||
) -> Any:
|
||||
"""List domains, globally or scoped under an entity."""
|
||||
params = {"page": page, "per_page": per_page}
|
||||
if scope and entity_id:
|
||||
validate_scope(scope)
|
||||
return self.request(
|
||||
"GET", f"{scope}/{self._q(entity_id)}/domains", params=params
|
||||
)
|
||||
return self.request("GET", "domains", params=params)
|
||||
|
||||
def domain(self, domain_id: str) -> Any:
|
||||
return self.request("GET", f"domains/{self._q(domain_id)}")
|
||||
|
||||
def customers(self, reseller_id: str, page: int = 1, per_page: int = 25) -> Any:
|
||||
return self.request(
|
||||
"GET",
|
||||
f"resellers/{self._q(reseller_id)}/customers",
|
||||
params={"page": page, "per_page": per_page},
|
||||
)
|
||||
|
||||
def customer(self, customer_id: str) -> Any:
|
||||
return self.request("GET", f"customers/{self._q(customer_id)}")
|
||||
|
||||
def users(
|
||||
self, scope: str, entity_id: str, page: int = 1, per_page: int = 25
|
||||
) -> Any:
|
||||
validate_scope(scope)
|
||||
return self.request(
|
||||
"GET",
|
||||
f"{scope}/{self._q(entity_id)}/users",
|
||||
params={"page": page, "per_page": per_page},
|
||||
)
|
||||
|
||||
def user(self, user_id: str) -> Any:
|
||||
return self.request("GET", f"users/{self._q(user_id)}")
|
||||
|
||||
def find_user(self, address: str) -> Any:
|
||||
"""Find a user / alias by email address.
|
||||
|
||||
This is a READ despite being a POST — it is NOT gated.
|
||||
"""
|
||||
return self.request(
|
||||
"POST", "users/find_by_address", json_body={"address": address}
|
||||
)
|
||||
|
||||
def logs(
|
||||
self,
|
||||
scope: str,
|
||||
entity_id: str,
|
||||
sort_direction: Optional[str] = None,
|
||||
sort_field: Optional[str] = None,
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
sender: Optional[str] = None,
|
||||
recipient: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
decision: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Mail-flow logs for an entity (passes through the standard log filters)."""
|
||||
validate_scope(scope)
|
||||
params = {
|
||||
"sort_direction": sort_direction,
|
||||
"sort_field": sort_field,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"sender": sender,
|
||||
"recipient": recipient,
|
||||
"subject": subject,
|
||||
"decision": decision,
|
||||
}
|
||||
return self.request(
|
||||
"GET", f"{scope}/{self._q(entity_id)}/logs", params=params
|
||||
)
|
||||
|
||||
def messages(
|
||||
self,
|
||||
scope: str,
|
||||
entity_id: str,
|
||||
sort_direction: Optional[str] = None,
|
||||
sort_field: Optional[str] = None,
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
sender: Optional[str] = None,
|
||||
recipient: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
decision: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Held / quarantined messages for an entity (same filters as logs)."""
|
||||
validate_scope(scope)
|
||||
params = {
|
||||
"sort_direction": sort_direction,
|
||||
"sort_field": sort_field,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"sender": sender,
|
||||
"recipient": recipient,
|
||||
"subject": subject,
|
||||
"decision": decision,
|
||||
}
|
||||
return self.request(
|
||||
"GET", f"{scope}/{self._q(entity_id)}/messages", params=params
|
||||
)
|
||||
|
||||
def configuration(self, scope: str, entity_id: str) -> Any:
|
||||
"""Entity configuration (includes permissions.messages.allow_spam_release)."""
|
||||
validate_scope(scope)
|
||||
return self.request("GET", f"{scope}/{self._q(entity_id)}/configuration")
|
||||
|
||||
def allow_block_rules(self, scope: str, entity_id: str) -> Any:
|
||||
validate_scope(scope)
|
||||
return self.request(
|
||||
"GET", f"{scope}/{self._q(entity_id)}/allow_block_rules"
|
||||
)
|
||||
|
||||
# ======================================================================
|
||||
# WRITE METHODS (gated — the CLI requires --confirm before calling these)
|
||||
# ======================================================================
|
||||
def release_message(
|
||||
self, message_id: str, recipients: Optional[str] = None
|
||||
) -> Any:
|
||||
"""Release (deliver) one held message. POST /messages/{id}/deliver."""
|
||||
body: dict = {"include_original_recipients": 1}
|
||||
if recipients:
|
||||
body["recipients"] = recipients
|
||||
return self.request(
|
||||
"POST", f"messages/{self._q(message_id)}/deliver", json_body=body
|
||||
)
|
||||
|
||||
def release_many(
|
||||
self,
|
||||
scope: str,
|
||||
entity_id: str,
|
||||
ids: Optional[str] = None,
|
||||
all_selected: bool = False,
|
||||
recipients: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Bulk-release held messages under an entity. POST .../messages/deliver_many."""
|
||||
validate_scope(scope)
|
||||
body: dict = {
|
||||
"include_original_recipients": 1,
|
||||
"all_selected": all_selected,
|
||||
"ids": ids or "",
|
||||
}
|
||||
if recipients:
|
||||
body["recipients"] = recipients
|
||||
return self.request(
|
||||
"POST",
|
||||
f"{scope}/{self._q(entity_id)}/messages/deliver_many",
|
||||
json_body=body,
|
||||
)
|
||||
|
||||
def add_rule(
|
||||
self, scope: str, entity_id: str, value: str, rule_type: str
|
||||
) -> Any:
|
||||
"""Add an allow / block rule on an entity. POST .../allow_block_rules."""
|
||||
validate_scope(scope)
|
||||
if rule_type not in ("allow", "block"):
|
||||
raise MailprotectorError("rule_type must be 'allow' or 'block'")
|
||||
return self.request(
|
||||
"POST",
|
||||
f"{scope}/{self._q(entity_id)}/allow_block_rules",
|
||||
json_body={"value": value, "rule_type": rule_type},
|
||||
)
|
||||
|
||||
def enable_release(self, scope: str, entity_id: str) -> Any:
|
||||
"""Enable spam release on an entity. PUT .../configuration.
|
||||
|
||||
Sets permissions.messages.allow_spam_release = true. Without this, the
|
||||
entity's held spam cannot be released.
|
||||
"""
|
||||
validate_scope(scope)
|
||||
return self.request(
|
||||
"PUT",
|
||||
f"{scope}/{self._q(entity_id)}/configuration",
|
||||
json_body={"permissions": {"messages": {"allow_spam_release": True}}},
|
||||
)
|
||||
@@ -1,142 +1,130 @@
|
||||
---
|
||||
name: memory-dream
|
||||
description: >-
|
||||
Memory lint + consolidation analyzer for the ClaudeTools REPO memory store
|
||||
(.claude/memory/). Audits the index, backlinks, referenced file paths,
|
||||
duplicate/overlap clusters, stale dated facts, and drift against the
|
||||
machine-local harness profile memory store. Default run is read-only.
|
||||
--apply-safe performs the low-risk fixes (append missing index lines, copy
|
||||
any profile-only files into the repo for indexing). Cluster merges, dedup
|
||||
deletes, and stale-fact removal are surfaced as PROPOSED actions for a
|
||||
human to apply -- they're judgment calls, not automation candidates. (Repo
|
||||
is the source of truth as of 2026-06-02; sync-memory.sh mirrors repo to
|
||||
profile, so PROFILE-side cleanup is handled by that script, not here. See
|
||||
feedback_memory_sync_destructive_ok.md.) Invoke for: "memory dream",
|
||||
"consolidate memory", "memory lint", "clean up memory", "memory errors",
|
||||
"dedupe memory".
|
||||
---
|
||||
|
||||
# Memory Dream
|
||||
|
||||
A read-only-by-default analyzer that flags issues in the shared memory store.
|
||||
Mutating ops are gated behind `--apply-safe` (for low-risk fixes) or the
|
||||
PROPOSED section (for judgment calls a human resolves by hand).
|
||||
|
||||
## The two-store model (important)
|
||||
|
||||
There are TWO separate memory stores on every machine:
|
||||
|
||||
- REPO store -- `.claude/memory/` (88+ `*.md` files + `MEMORY.md` index).
|
||||
Tracked in git, syncs to all machines via Gitea. **This is the source of
|
||||
truth.** `CLAUDE.md` mandates writing here.
|
||||
- HARNESS PROFILE store -- `$HOME/.claude/projects/<slug>/memory/`. Machine
|
||||
local, NOT in git, NOT synced. This is the store the Claude Code harness
|
||||
auto-injects into the system prompt at session start.
|
||||
|
||||
The two drift over time. `memory-dream` reports that drift in its report
|
||||
section. The companion script `.claude/scripts/sync-memory.sh` is what
|
||||
actually reconciles them: it runs in **mirror mode** (since 2026-06-02) —
|
||||
repo is authoritative, profile is synced to match (deletions propagate;
|
||||
repo content wins on conflict). PROFILE-side hygiene lives in
|
||||
`sync-memory.sh`, not here.
|
||||
|
||||
## What it checks
|
||||
|
||||
`scripts/memory_dream.py` runs six READ-ONLY analyses over the REPO store:
|
||||
|
||||
1. INDEX RECONCILE -- orphan files (no `MEMORY.md` line), index lines whose
|
||||
target file is missing, and frontmatter `name:` vs filename signals.
|
||||
2. BACKLINKS -- `[[name]]` references in bodies whose target slug has no file.
|
||||
3. REFERENCED-ARTIFACT VALIDITY -- conservatively extracts repo-relative file
|
||||
paths / script names from each body (backtick-wrapped single tokens only)
|
||||
and flags ones not found in the repo. Reported as **verify**, never delete
|
||||
(many are legitimately server-side or in sibling repos).
|
||||
4. DUPLICATE / OVERLAP CLUSTERS -- groups memories by type + token-overlap /
|
||||
shared slug-prefix and lists candidate mergeable clusters (e.g. the many
|
||||
`feedback_syncro_*` files). **Proposes** merges; never performs them.
|
||||
5. STALE DATED FACTS -- flags `project`-type memories with an "as of <date>"
|
||||
style claim older than ~6 months for re-verification.
|
||||
6. DRIFT vs PROFILE STORE -- locates the harness profile memory dir for this
|
||||
project and reports profile-only files (candidates to migrate INTO the repo)
|
||||
and repo-only files (candidates to push OUT to profile). Report only.
|
||||
|
||||
The report ends with a `## PROPOSED (needs human approval)` section that is
|
||||
NEVER auto-applied.
|
||||
|
||||
## Modes
|
||||
|
||||
- Default (no flag) -- **REPORT ONLY. Mutates nothing.** Writes a timestamped
|
||||
report to `.claude/memory/_reports/YYYY-MM-DD-HHMM-dream.md` (created if
|
||||
missing) and prints it to stdout.
|
||||
- `--apply-safe` -- performs ONLY additive, non-destructive fixes and prints
|
||||
each action:
|
||||
- (a) append missing index lines to `MEMORY.md` for orphan files, under the
|
||||
correct `## <Type>` header, never reordering or removing existing lines;
|
||||
- (b) copy profile-only memory files INTO the repo store (additive
|
||||
migration). If a same-named repo file already exists it is SKIPPED and the
|
||||
conflict is reported -- it is never overwritten.
|
||||
- `--no-file` -- print to stdout only; skip writing the `_reports/` file.
|
||||
- `--report-file <path>` -- write the report to an explicit path.
|
||||
|
||||
### What dream does NOT auto-do
|
||||
|
||||
`memory-dream` does NOT, even with `--apply-safe`:
|
||||
|
||||
- delete a repo memory file (cluster dedup is a judgment call — pick which file becomes canonical, fold the others' content, retire the originals deliberately);
|
||||
- remove or reorder index lines (index cleanups are also surfaced as proposals);
|
||||
- overwrite a file whose content differs;
|
||||
- perform a proposed merge.
|
||||
|
||||
These stay in the report's `## PROPOSED` section. The rationale isn't "never delete" any more (the fleet-wide additive safety net was dropped 2026-06-02; see `feedback_memory_sync_destructive_ok.md`) — it's that merges and dedups require human judgment about which file is canonical and how to combine content. Profile-side deletion DOES happen automatically — but in `sync-memory.sh`, not here.
|
||||
|
||||
## Running it
|
||||
|
||||
This machine's Python launcher is `py` (per identity.json); the script also
|
||||
runs under `python` / `python3`. Stdlib only -- no pip deps.
|
||||
|
||||
```bash
|
||||
# REPORT ONLY (default) -- writes _reports/<stamp>-dream.md and prints it
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/memory-dream/scripts/memory_dream.py"
|
||||
|
||||
# report to stdout only, write nothing
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/memory-dream/scripts/memory_dream.py" --no-file
|
||||
|
||||
# additive-only fixes (append orphan index lines, migrate profile-only files)
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/memory-dream/scripts/memory_dream.py" --apply-safe
|
||||
```
|
||||
|
||||
`CLAUDETOOLS_ROOT` resolves from the env var, else `claudetools_root` in
|
||||
`.claude/identity.json`, else the repo root derived from the script's own
|
||||
location -- no hardcoded drive letters.
|
||||
|
||||
## Cleanup / approve workflow
|
||||
|
||||
1. Run with no flag. Read the report (stdout or `_reports/<stamp>-dream.md`).
|
||||
2. Run `--apply-safe` to take the safe additive wins: orphan index lines get
|
||||
added, profile-only memories get migrated into the repo (conflicts skipped
|
||||
and reported).
|
||||
3. Work the `## PROPOSED` section by hand:
|
||||
- `[MERGE?]` -- decide whether to consolidate a cluster. If yes, author a new
|
||||
combined memory (or set of files for a rule/history split), retire the
|
||||
originals via `git rm`, update `MEMORY.md`. Deletions are now first-class
|
||||
— `sync-memory.sh` mirror mode will propagate them to every profile store
|
||||
on the next run.
|
||||
- `[REVERIFY?]` -- confirm the dated fact still holds; update the body and
|
||||
its date if it changed.
|
||||
- `[STALE-REF?]` -- confirm the referenced path moved/renamed; repoint or
|
||||
annotate. Many are legitimately server-side (`.service` units, `/opt/...`).
|
||||
- `[INDEX-CLEANUP?]` / `[DRIFT-RESOLVE?]` -- human picks the winner.
|
||||
4. Commit the repo store changes so they sync to the fleet via Gitea.
|
||||
|
||||
## Self-test
|
||||
|
||||
`scripts/selftest.py` runs the analyzer against a synthetic fixture memory
|
||||
store in a temp dir and asserts each detector fires (orphan, missing target,
|
||||
broken backlink, stale path, cluster, profile drift) and that `--apply-safe`
|
||||
only touches the things it's supposed to (index appends + profile→repo copy
|
||||
of new files; no deletions, no merges, no overwrites of differing content).
|
||||
Run:
|
||||
|
||||
```bash
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/memory-dream/scripts/selftest.py"
|
||||
```
|
||||
---
|
||||
name: memory-dream
|
||||
description: "Lint + consolidate the ClaudeTools repo memory store (.claude/memory/): audits index, backlinks, file paths, duplicate clusters, stale facts. Read-only default; --apply-safe does low-risk fixes; merges/deletes surfaced as proposals. Triggers: memory dream, consolidate/lint/clean up/dedupe memory."
|
||||
|
||||
---
|
||||
|
||||
# Memory Dream
|
||||
|
||||
A read-only-by-default analyzer that flags issues in the shared memory store.
|
||||
Mutating ops are gated behind `--apply-safe` (for low-risk fixes) or the
|
||||
PROPOSED section (for judgment calls a human resolves by hand).
|
||||
|
||||
## The two-store model (important)
|
||||
|
||||
There are TWO separate memory stores on every machine:
|
||||
|
||||
- REPO store -- `.claude/memory/` (88+ `*.md` files + `MEMORY.md` index).
|
||||
Tracked in git, syncs to all machines via Gitea. **This is the source of
|
||||
truth.** `CLAUDE.md` mandates writing here.
|
||||
- HARNESS PROFILE store -- `$HOME/.claude/projects/<slug>/memory/`. Machine
|
||||
local, NOT in git, NOT synced. This is the store the Claude Code harness
|
||||
auto-injects into the system prompt at session start.
|
||||
|
||||
The two drift over time. `memory-dream` reports that drift in its report
|
||||
section. The companion script `.claude/scripts/sync-memory.sh` is what
|
||||
actually reconciles them: it runs in **mirror mode** (since 2026-06-02) —
|
||||
repo is authoritative, profile is synced to match (deletions propagate;
|
||||
repo content wins on conflict). PROFILE-side hygiene lives in
|
||||
`sync-memory.sh`, not here.
|
||||
|
||||
## What it checks
|
||||
|
||||
`scripts/memory_dream.py` runs six READ-ONLY analyses over the REPO store:
|
||||
|
||||
1. INDEX RECONCILE -- orphan files (no `MEMORY.md` line), index lines whose
|
||||
target file is missing, and frontmatter `name:` vs filename signals.
|
||||
2. BACKLINKS -- `[[name]]` references in bodies whose target slug has no file.
|
||||
3. REFERENCED-ARTIFACT VALIDITY -- conservatively extracts repo-relative file
|
||||
paths / script names from each body (backtick-wrapped single tokens only)
|
||||
and flags ones not found in the repo. Reported as **verify**, never delete
|
||||
(many are legitimately server-side or in sibling repos).
|
||||
4. DUPLICATE / OVERLAP CLUSTERS -- groups memories by type + token-overlap /
|
||||
shared slug-prefix and lists candidate mergeable clusters (e.g. the many
|
||||
`feedback_syncro_*` files). **Proposes** merges; never performs them.
|
||||
5. STALE DATED FACTS -- flags `project`-type memories with an "as of <date>"
|
||||
style claim older than ~6 months for re-verification.
|
||||
6. DRIFT vs PROFILE STORE -- locates the harness profile memory dir for this
|
||||
project and reports profile-only files (candidates to migrate INTO the repo)
|
||||
and repo-only files (candidates to push OUT to profile). Report only.
|
||||
|
||||
The report ends with a `## PROPOSED (needs human approval)` section that is
|
||||
NEVER auto-applied.
|
||||
|
||||
## Modes
|
||||
|
||||
- Default (no flag) -- **REPORT ONLY. Mutates nothing.** Writes a timestamped
|
||||
report to `.claude/memory/_reports/YYYY-MM-DD-HHMM-dream.md` (created if
|
||||
missing) and prints it to stdout.
|
||||
- `--apply-safe` -- performs ONLY additive, non-destructive fixes and prints
|
||||
each action:
|
||||
- (a) append missing index lines to `MEMORY.md` for orphan files, under the
|
||||
correct `## <Type>` header, never reordering or removing existing lines;
|
||||
- (b) copy profile-only memory files INTO the repo store (additive
|
||||
migration). If a same-named repo file already exists it is SKIPPED and the
|
||||
conflict is reported -- it is never overwritten.
|
||||
- `--no-file` -- print to stdout only; skip writing the `_reports/` file.
|
||||
- `--report-file <path>` -- write the report to an explicit path.
|
||||
|
||||
### What dream does NOT auto-do
|
||||
|
||||
`memory-dream` does NOT, even with `--apply-safe`:
|
||||
|
||||
- delete a repo memory file (cluster dedup is a judgment call — pick which file becomes canonical, fold the others' content, retire the originals deliberately);
|
||||
- remove or reorder index lines (index cleanups are also surfaced as proposals);
|
||||
- overwrite a file whose content differs;
|
||||
- perform a proposed merge.
|
||||
|
||||
These stay in the report's `## PROPOSED` section. The rationale isn't "never delete" any more (the fleet-wide additive safety net was dropped 2026-06-02; see `feedback_memory_sync_destructive_ok.md`) — it's that merges and dedups require human judgment about which file is canonical and how to combine content. Profile-side deletion DOES happen automatically — but in `sync-memory.sh`, not here.
|
||||
|
||||
## Running it
|
||||
|
||||
This machine's Python launcher is `py` (per identity.json); the script also
|
||||
runs under `python` / `python3`. Stdlib only -- no pip deps.
|
||||
|
||||
```bash
|
||||
# REPORT ONLY (default) -- writes _reports/<stamp>-dream.md and prints it
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/memory-dream/scripts/memory_dream.py"
|
||||
|
||||
# report to stdout only, write nothing
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/memory-dream/scripts/memory_dream.py" --no-file
|
||||
|
||||
# additive-only fixes (append orphan index lines, migrate profile-only files)
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/memory-dream/scripts/memory_dream.py" --apply-safe
|
||||
```
|
||||
|
||||
`CLAUDETOOLS_ROOT` resolves from the env var, else `claudetools_root` in
|
||||
`.claude/identity.json`, else the repo root derived from the script's own
|
||||
location -- no hardcoded drive letters.
|
||||
|
||||
## Cleanup / approve workflow
|
||||
|
||||
1. Run with no flag. Read the report (stdout or `_reports/<stamp>-dream.md`).
|
||||
2. Run `--apply-safe` to take the safe additive wins: orphan index lines get
|
||||
added, profile-only memories get migrated into the repo (conflicts skipped
|
||||
and reported).
|
||||
3. Work the `## PROPOSED` section by hand:
|
||||
- `[MERGE?]` -- decide whether to consolidate a cluster. If yes, author a new
|
||||
combined memory (or set of files for a rule/history split), retire the
|
||||
originals via `git rm`, update `MEMORY.md`. Deletions are now first-class
|
||||
— `sync-memory.sh` mirror mode will propagate them to every profile store
|
||||
on the next run.
|
||||
- `[REVERIFY?]` -- confirm the dated fact still holds; update the body and
|
||||
its date if it changed.
|
||||
- `[STALE-REF?]` -- confirm the referenced path moved/renamed; repoint or
|
||||
annotate. Many are legitimately server-side (`.service` units, `/opt/...`).
|
||||
- `[INDEX-CLEANUP?]` / `[DRIFT-RESOLVE?]` -- human picks the winner.
|
||||
4. Commit the repo store changes so they sync to the fleet via Gitea.
|
||||
|
||||
## Self-test
|
||||
|
||||
`scripts/selftest.py` runs the analyzer against a synthetic fixture memory
|
||||
store in a temp dir and asserts each detector fires (orphan, missing target,
|
||||
broken backlink, stale path, cluster, profile drift) and that `--apply-safe`
|
||||
only touches the things it's supposed to (index appends + profile→repo copy
|
||||
of new files; no deletions, no merges, no overwrites of differing content).
|
||||
Run:
|
||||
|
||||
```bash
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/memory-dream/scripts/selftest.py"
|
||||
```
|
||||
|
||||
@@ -1,127 +1,115 @@
|
||||
---
|
||||
name: packetdial
|
||||
description: >-
|
||||
Manage the Arizona Computer Guru (ACG) PacketDial / OITVOIP hosted-VoIP
|
||||
platform via the NetSapiens SNAPsolution API v2 (pbx.packetdial.com,
|
||||
v44.4). List and inspect domains, users, devices/phones, DIDs (phone
|
||||
numbers), resellers, and pull CDRs (call detail records). Provision new
|
||||
customer domains, users, SIP devices, and phone numbers (all writes gated
|
||||
behind --confirm). Read-only by default. Invoke for: "packetdial",
|
||||
"oitvoip", "oit voip", "netsapiens", "voip portal", "pbx portal", "voip
|
||||
domain", "voip user", "voip extension", "provision phone", "add did",
|
||||
"phone number on voip", "call detail records", "cdr", "voip.packetdial",
|
||||
"pbx.packetdial". NOTE: voip.packetdial.com is the customer-facing portal
|
||||
(the fax/UC dashboard, e.g. Cascades account 28598) and has no API — the
|
||||
programmable surface is pbx.packetdial.com. This skill talks to the LIVE
|
||||
production reseller PBX; treat writes conservatively.
|
||||
---
|
||||
|
||||
# PacketDial / NetSapiens (OITVOIP) Skill
|
||||
|
||||
Standalone CLI client for the NetSapiens SNAPsolution **API v2** that backs
|
||||
ACG's hosted-VoIP offering through OITVOIP / PacketDial. Read-only by default;
|
||||
every write (create / update / delete) is gated behind `--confirm`.
|
||||
|
||||
## The two hostnames (important)
|
||||
|
||||
| Host | What it is | API? |
|
||||
|---|---|---|
|
||||
| `voip.packetdial.com` | Customer-facing white-label portal / UC & fax dashboard (e.g. Cascades fax account **28598**). Login-gated UI. | **No** |
|
||||
| `pbx.packetdial.com` | Reseller PBX platform — NetSapiens v44.4. | **Yes** — this skill targets it |
|
||||
|
||||
- API base: `https://pbx.packetdial.com/ns-api/v2`
|
||||
- Token endpoint: `https://pbx.packetdial.com/ns-api/v2/tokens`
|
||||
- Live OpenAPI spec: `https://pbx.packetdial.com/ns-api/webroot/openapi/openapi.json`
|
||||
- Live Swagger UI: `https://pbx.packetdial.com/ns-api/openapi`
|
||||
- Vendor docs: https://docs.ns-api.com/ (login) and https://voipdocs.io/oitvoip-access-platform-apis
|
||||
|
||||
## Credentials — ONE-TIME SETUP (not yet provisioned)
|
||||
|
||||
As of this skill's creation **no API key exists yet** — the vault entry
|
||||
`msp-tools/oitvoip.sops.yaml` is empty/absent, so every command will fail with a
|
||||
clear "No credentials found" error until you do this once:
|
||||
|
||||
1. Log into `pbx.packetdial.com` -> **Admin > API Keys** and create a
|
||||
reseller-scoped key (prefix `nsr_`). If self-service key creation is not
|
||||
available, reply to **Darwin Escaro (OITVOIP)** for reseller OAuth client
|
||||
credentials.
|
||||
2. Store it in the SOPS vault. Preferred (static bearer key):
|
||||
```
|
||||
# msp-tools/oitvoip.sops.yaml
|
||||
credentials:
|
||||
api_key: nsr_xxxxxxxxxxxxxxxx
|
||||
```
|
||||
Or, for OAuth2 password-grant credentials:
|
||||
```
|
||||
credentials:
|
||||
client_id: <client id>
|
||||
client_secret: <client secret>
|
||||
username: <portal user@domain>
|
||||
password: <portal password>
|
||||
```
|
||||
3. That's it — the client auto-detects which shape is present.
|
||||
|
||||
The client never hardcodes secrets. Resolution order: `PACKETDIAL_API_KEY` env
|
||||
-> `PACKETDIAL_CLIENT_ID`+friends env -> vault `credentials.api_key` -> vault
|
||||
OAuth fields. Env overrides exist for quick testing without touching the vault.
|
||||
|
||||
## Running the CLI
|
||||
|
||||
This machine's Python launcher is `py` (per identity.json); `python` / `python3`
|
||||
also work. Run from the scripts dir so the two modules resolve.
|
||||
|
||||
```bash
|
||||
cd C:/claudetools/.claude/skills/packetdial/scripts
|
||||
|
||||
py ns.py status # API version + authenticated key identity
|
||||
py ns.py domains # list all domains
|
||||
py ns.py domain <domain> # one domain's config
|
||||
py ns.py users <domain> # users / extensions in a domain
|
||||
py ns.py user <domain> <user>
|
||||
py ns.py phones <domain> # SIP devices registered in a domain
|
||||
py ns.py dids <domain> # phone numbers (DIDs) on a domain
|
||||
py ns.py devices <domain> <user>
|
||||
py ns.py cdrs --domain <domain> --start 2026-06-01 --end 2026-06-02
|
||||
py ns.py resellers
|
||||
```
|
||||
|
||||
## Writes (gated)
|
||||
|
||||
Every mutating command prints a `[DRY RUN]` line and exits non-zero unless you
|
||||
pass `--confirm`. Bodies are raw JSON matching the NetSapiens v2 schema.
|
||||
|
||||
```bash
|
||||
py ns.py create-domain --body '{"domain":"acme","description":"Acme Inc"}' --confirm
|
||||
py ns.py create-user acme --body '{"user":"101","name-first-name":"Jane"}' --confirm
|
||||
py ns.py create-phone acme --body '{...}' --confirm
|
||||
py ns.py create-did acme --body '{"phonenumber":"15205551234"}' --confirm
|
||||
py ns.py update-user acme 101 --body '{"name-last-name":"Doe"}' --confirm
|
||||
py ns.py delete-user acme 101 --confirm
|
||||
```
|
||||
|
||||
## Raw escape hatch (any of the 239 v2 paths)
|
||||
|
||||
The named commands cover the common surface; for anything else, hit the path
|
||||
directly. Non-GET methods still require `--confirm`.
|
||||
|
||||
```bash
|
||||
py ns.py raw GET domains/acme/users/101/answerrules
|
||||
py ns.py raw POST domains/acme/users --body '{...}' --confirm
|
||||
```
|
||||
|
||||
## Standard provisioning flow (new customer)
|
||||
|
||||
1. `create-domain` -> dial plan auto-generates
|
||||
2. `create-user` per extension
|
||||
3. `create-phone` per SIP device (MAC-provisioned)
|
||||
4. `create-did` to attach DIDs and route them to users
|
||||
5. Log the work back to the Syncro ticket
|
||||
|
||||
## Notes
|
||||
|
||||
- This is the LIVE production reseller PBX. A bad `create-domain` or
|
||||
`delete-user` affects real customers — confirm the target domain first with a
|
||||
read command before any write.
|
||||
- CDR queries can be large; always pass `--start`/`--end` and a `--limit`.
|
||||
- Reference detail (auth shapes, full endpoint inventory) lives in
|
||||
`references/api.md`.
|
||||
---
|
||||
name: packetdial
|
||||
description: "Manage the ACG PacketDial/OITVOIP hosted VoIP via the NetSapiens API (pbx.packetdial.com). List/inspect domains, users, devices, DIDs, resellers; pull CDRs; provision domains/users/SIP/numbers (writes gated --confirm; read-only default). Triggers: packetdial, oitvoip, netsapiens, voip domain/user/extension, provision phone, add did, CDR. Live production PBX."
|
||||
|
||||
---
|
||||
|
||||
# PacketDial / NetSapiens (OITVOIP) Skill
|
||||
|
||||
Standalone CLI client for the NetSapiens SNAPsolution **API v2** that backs
|
||||
ACG's hosted-VoIP offering through OITVOIP / PacketDial. Read-only by default;
|
||||
every write (create / update / delete) is gated behind `--confirm`.
|
||||
|
||||
## The two hostnames (important)
|
||||
|
||||
| Host | What it is | API? |
|
||||
|---|---|---|
|
||||
| `voip.packetdial.com` | Customer-facing white-label portal / UC & fax dashboard (e.g. Cascades fax account **28598**). Login-gated UI. | **No** |
|
||||
| `pbx.packetdial.com` | Reseller PBX platform — NetSapiens v44.4. | **Yes** — this skill targets it |
|
||||
|
||||
- API base: `https://pbx.packetdial.com/ns-api/v2`
|
||||
- Token endpoint: `https://pbx.packetdial.com/ns-api/v2/tokens`
|
||||
- Live OpenAPI spec: `https://pbx.packetdial.com/ns-api/webroot/openapi/openapi.json`
|
||||
- Live Swagger UI: `https://pbx.packetdial.com/ns-api/openapi`
|
||||
- Vendor docs: https://docs.ns-api.com/ (login) and https://voipdocs.io/oitvoip-access-platform-apis
|
||||
|
||||
## Credentials — ONE-TIME SETUP (not yet provisioned)
|
||||
|
||||
As of this skill's creation **no API key exists yet** — the vault entry
|
||||
`msp-tools/oitvoip.sops.yaml` is empty/absent, so every command will fail with a
|
||||
clear "No credentials found" error until you do this once:
|
||||
|
||||
1. Log into `pbx.packetdial.com` -> **Admin > API Keys** and create a
|
||||
reseller-scoped key (prefix `nsr_`). If self-service key creation is not
|
||||
available, reply to **Darwin Escaro (OITVOIP)** for reseller OAuth client
|
||||
credentials.
|
||||
2. Store it in the SOPS vault. Preferred (static bearer key):
|
||||
```
|
||||
# msp-tools/oitvoip.sops.yaml
|
||||
credentials:
|
||||
api_key: nsr_xxxxxxxxxxxxxxxx
|
||||
```
|
||||
Or, for OAuth2 password-grant credentials:
|
||||
```
|
||||
credentials:
|
||||
client_id: <client id>
|
||||
client_secret: <client secret>
|
||||
username: <portal user@domain>
|
||||
password: <portal password>
|
||||
```
|
||||
3. That's it — the client auto-detects which shape is present.
|
||||
|
||||
The client never hardcodes secrets. Resolution order: `PACKETDIAL_API_KEY` env
|
||||
-> `PACKETDIAL_CLIENT_ID`+friends env -> vault `credentials.api_key` -> vault
|
||||
OAuth fields. Env overrides exist for quick testing without touching the vault.
|
||||
|
||||
## Running the CLI
|
||||
|
||||
This machine's Python launcher is `py` (per identity.json); `python` / `python3`
|
||||
also work. Run from the scripts dir so the two modules resolve.
|
||||
|
||||
```bash
|
||||
cd C:/claudetools/.claude/skills/packetdial/scripts
|
||||
|
||||
py ns.py status # API version + authenticated key identity
|
||||
py ns.py domains # list all domains
|
||||
py ns.py domain <domain> # one domain's config
|
||||
py ns.py users <domain> # users / extensions in a domain
|
||||
py ns.py user <domain> <user>
|
||||
py ns.py phones <domain> # SIP devices registered in a domain
|
||||
py ns.py dids <domain> # phone numbers (DIDs) on a domain
|
||||
py ns.py devices <domain> <user>
|
||||
py ns.py cdrs --domain <domain> --start 2026-06-01 --end 2026-06-02
|
||||
py ns.py resellers
|
||||
```
|
||||
|
||||
## Writes (gated)
|
||||
|
||||
Every mutating command prints a `[DRY RUN]` line and exits non-zero unless you
|
||||
pass `--confirm`. Bodies are raw JSON matching the NetSapiens v2 schema.
|
||||
|
||||
```bash
|
||||
py ns.py create-domain --body '{"domain":"acme","description":"Acme Inc"}' --confirm
|
||||
py ns.py create-user acme --body '{"user":"101","name-first-name":"Jane"}' --confirm
|
||||
py ns.py create-phone acme --body '{...}' --confirm
|
||||
py ns.py create-did acme --body '{"phonenumber":"15205551234"}' --confirm
|
||||
py ns.py update-user acme 101 --body '{"name-last-name":"Doe"}' --confirm
|
||||
py ns.py delete-user acme 101 --confirm
|
||||
```
|
||||
|
||||
## Raw escape hatch (any of the 239 v2 paths)
|
||||
|
||||
The named commands cover the common surface; for anything else, hit the path
|
||||
directly. Non-GET methods still require `--confirm`.
|
||||
|
||||
```bash
|
||||
py ns.py raw GET domains/acme/users/101/answerrules
|
||||
py ns.py raw POST domains/acme/users --body '{...}' --confirm
|
||||
```
|
||||
|
||||
## Standard provisioning flow (new customer)
|
||||
|
||||
1. `create-domain` -> dial plan auto-generates
|
||||
2. `create-user` per extension
|
||||
3. `create-phone` per SIP device (MAC-provisioned)
|
||||
4. `create-did` to attach DIDs and route them to users
|
||||
5. Log the work back to the Syncro ticket
|
||||
|
||||
## Notes
|
||||
|
||||
- This is the LIVE production reseller PBX. A bad `create-domain` or
|
||||
`delete-user` affects real customers — confirm the target domain first with a
|
||||
read command before any write.
|
||||
- CDR queries can be large; always pass `--start`/`--end` and a `--limit`.
|
||||
- Reference detail (auth shapes, full endpoint inventory) lives in
|
||||
`references/api.md`.
|
||||
|
||||
@@ -1,65 +1,61 @@
|
||||
---
|
||||
name: remediation-tool
|
||||
description: |
|
||||
M365 tenant investigation and remediation using the ComputerGuru tiered MSP app suite (5 apps: Security Investigator, Exchange Operator, User Manager, Tenant Admin, Defender Add-on). Auto-invoke when the user says "remediation tool", "365 remediation", "check <user>'s mailbox/box", "credential stuffing" against an M365 user, "breach check" on an M365 tenant, or needs M365 admin API work that client-credentials Graph + Exchange REST can perform. NOT for CIPP — this is the direct Graph API app suite.
|
||||
|
||||
Also invoke when the user needs any of: inbox rule enumeration, mailbox forwarding check, delegate/SendAs audit, OAuth consent audit, sign-in log queries, risky user lookup, directory audit queries, B2B guest invite audit against M365.
|
||||
|
||||
Triggers: "365 remediation", "remediation tool", "check <user> box/mailbox/account for breach", "credential stuff*", "who's getting attacked", "foreign sign-in", "inbox rule", "mailbox forward*", "oauth consent" (in MSP context), "tenant sweep", "risky user", "hidden rule", Exchange Online admin API, "adminapi/beta/{tenant}/InvokeCommand".
|
||||
---
|
||||
|
||||
# 365 Remediation Tool
|
||||
|
||||
Read-only by default. All remediation actions require explicit `YES` confirmation in chat (not a permission prompt).
|
||||
|
||||
## App Architecture (Tiered)
|
||||
|
||||
Five multi-tenant apps cover distinct privilege tiers. Use only what the task requires.
|
||||
|
||||
| Tier | App display name | App ID | Vault file | Scope |
|
||||
|---|---|---|---|---|
|
||||
| `investigator` | ComputerGuru Security Investigator | `bfbc12a4-f0dd-4e12-b06d-997e7271e10c` | `computerguru-security-investigator.sops.yaml` | Graph read-only |
|
||||
| `investigator-exo` | ComputerGuru Security Investigator | `bfbc12a4-f0dd-4e12-b06d-997e7271e10c` | `computerguru-security-investigator.sops.yaml` | Exchange Online read |
|
||||
| `exchange-op` | ComputerGuru Exchange Operator | `b43e7342-5b4b-492f-890f-bb5a4f7f40e9` | `computerguru-exchange-operator.sops.yaml` | Exchange Online write |
|
||||
| `user-manager` | ComputerGuru User Manager | `64fac46b-8b44-41ad-93ee-7da03927576c` | `computerguru-user-manager.sops.yaml` | Graph user/group write |
|
||||
| `tenant-admin` | ComputerGuru Tenant Admin | `709e6eed-0711-4875-9c44-2d3518c47063` | `computerguru-tenant-admin.sops.yaml` | Graph high-privilege |
|
||||
| `defender` | ComputerGuru Defender Add-on | `dbf8ad1a-54f4-4bb8-8a9e-ea5b9634635b` | `computerguru-defender-addon.sops.yaml` | Defender ATP (MDE only) |
|
||||
|
||||
**Default for breach checks:** use `investigator` (Graph) + `investigator-exo` (Exchange read). Escalate to write tiers only when remediating.
|
||||
|
||||
## Auto-Invocation Behavior
|
||||
|
||||
When triggered automatically (vs. via `/remediation-tool`), follow the same workflow in `.claude/commands/remediation-tool.md`:
|
||||
|
||||
1. Parse the user's intent into a subcommand (check/sweep/signins/consent-url/remediate).
|
||||
2. Resolve tenant ID from domain.
|
||||
3. Acquire tokens via `get-token.sh <tenant> <tier>` — use lowest-privilege tier needed.
|
||||
4. Run checks via scripts in `scripts/`.
|
||||
5. Interpret findings using `references/checklist.md`.
|
||||
6. Write report to `clients/{slug}/reports/YYYY-MM-DD-{action}.md` using `templates/breach-report.md`.
|
||||
7. Chat summary + delegate commit to Gitea agent.
|
||||
|
||||
## Before calling any script, verify
|
||||
|
||||
- The SOPS vault is accessible: `test -f D:/vault/scripts/vault.sh` (Windows) or `test -f ~/vault/scripts/vault.sh` (other).
|
||||
- `jq`, `curl`, `bash` are available.
|
||||
- For Exchange REST checks: confirm the target tenant has **Exchange Administrator** role assigned to the **Security Investigator** SP (for reads) or **Exchange Operator** SP (for writes). If any Exchange REST call returns 403, emit the tenant-scoped Entra Roles link from `references/gotchas.md`.
|
||||
- For Identity Protection checks: `IdentityRiskyUser.Read.All` is in the Security Investigator manifest AND the tenant has consented to that app. If 403, emit the per-app consent URL from `references/gotchas.md`.
|
||||
- For Defender checks: confirm tenant has Microsoft Defender for Endpoint (MDE) license before using `defender` tier — it returns AADSTS650052 otherwise.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Target identifiers**: accept UPN, domain, or tenant GUID. Normalize to tenant GUID internally.
|
||||
- **Token tiers**: minimum necessary privilege. Never use `tenant-admin` for a read-only check.
|
||||
- **Token cache**: `/tmp/remediation-tool/{tenant-id}/{tier}.jwt`. TTL 55 minutes. Check `-mmin -55` before reuse.
|
||||
- **Raw JSON artifacts**: `/tmp/remediation-tool/{tenant-id}/{check}/` — keep so the user can re-analyze.
|
||||
- **Reports**: `clients/{slug}/reports/YYYY-MM-DD-{action}.md`. Derive slug from domain (strip TLD, hyphenate).
|
||||
- **UTC dates everywhere**.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
- **Not a replacement for CIPP.** Use CIPP for bulk baseline configuration, templates, standards alerting. Use this tool for focused investigation and point-in-time remediation.
|
||||
- **Entra app registrations stay manual in the portal** — don't create/modify the multi-tenant apps themselves via the tool.
|
||||
- **Conditional Access policies CAN be managed programmatically** (Tenant Admin tier holds `Policy.ReadWrite.ConditionalAccess` + the Conditional Access Administrator role). MANDATORY discipline: (1) always create/modify in **report-only** (`state: enabledForReportingButNotEnforced`) first; (2) always **exclude the tenant's break-glass account** (`conditions.users.excludeUsers`); (3) verify impact in Entra sign-in logs before enforcing; (4) get explicit user confirmation before flipping any policy to `enabled` on a tenant with real users. (CA-manual boundary relaxed 2026-05-27 at Mike's direction — break-glass + report-only keep blast radius near zero.)
|
||||
- **Not for Graph permissions the apps don't have.** If a call 403s and the scope isn't in the relevant app's manifest, stop and tell the user — don't try to work around it.
|
||||
- **Defender tier requires MDE license.** If the tenant doesn't have MDE, the token request succeeds but API calls return AADSTS650052. Check before using.
|
||||
---
|
||||
name: remediation-tool
|
||||
description: "M365 tenant investigation + remediation via the ComputerGuru MSP app suite (Security Investigator/Exchange Operator/User Manager/Tenant Admin/Defender). Direct Graph+Exchange REST (not CIPP). Triggers: 365 remediation, breach/credential-stuffing check, check a mailbox, inbox rules, mailbox forwarding, delegate/SendAs audit, OAuth consent, sign-in/risky-user lookup, tenant sweep."
|
||||
|
||||
---
|
||||
|
||||
# 365 Remediation Tool
|
||||
|
||||
Read-only by default. All remediation actions require explicit `YES` confirmation in chat (not a permission prompt).
|
||||
|
||||
## App Architecture (Tiered)
|
||||
|
||||
Five multi-tenant apps cover distinct privilege tiers. Use only what the task requires.
|
||||
|
||||
| Tier | App display name | App ID | Vault file | Scope |
|
||||
|---|---|---|---|---|
|
||||
| `investigator` | ComputerGuru Security Investigator | `bfbc12a4-f0dd-4e12-b06d-997e7271e10c` | `computerguru-security-investigator.sops.yaml` | Graph read-only |
|
||||
| `investigator-exo` | ComputerGuru Security Investigator | `bfbc12a4-f0dd-4e12-b06d-997e7271e10c` | `computerguru-security-investigator.sops.yaml` | Exchange Online read |
|
||||
| `exchange-op` | ComputerGuru Exchange Operator | `b43e7342-5b4b-492f-890f-bb5a4f7f40e9` | `computerguru-exchange-operator.sops.yaml` | Exchange Online write |
|
||||
| `user-manager` | ComputerGuru User Manager | `64fac46b-8b44-41ad-93ee-7da03927576c` | `computerguru-user-manager.sops.yaml` | Graph user/group write |
|
||||
| `tenant-admin` | ComputerGuru Tenant Admin | `709e6eed-0711-4875-9c44-2d3518c47063` | `computerguru-tenant-admin.sops.yaml` | Graph high-privilege |
|
||||
| `defender` | ComputerGuru Defender Add-on | `dbf8ad1a-54f4-4bb8-8a9e-ea5b9634635b` | `computerguru-defender-addon.sops.yaml` | Defender ATP (MDE only) |
|
||||
|
||||
**Default for breach checks:** use `investigator` (Graph) + `investigator-exo` (Exchange read). Escalate to write tiers only when remediating.
|
||||
|
||||
## Auto-Invocation Behavior
|
||||
|
||||
When triggered automatically (vs. via `/remediation-tool`), follow the same workflow in `.claude/commands/remediation-tool.md`:
|
||||
|
||||
1. Parse the user's intent into a subcommand (check/sweep/signins/consent-url/remediate).
|
||||
2. Resolve tenant ID from domain.
|
||||
3. Acquire tokens via `get-token.sh <tenant> <tier>` — use lowest-privilege tier needed.
|
||||
4. Run checks via scripts in `scripts/`.
|
||||
5. Interpret findings using `references/checklist.md`.
|
||||
6. Write report to `clients/{slug}/reports/YYYY-MM-DD-{action}.md` using `templates/breach-report.md`.
|
||||
7. Chat summary + delegate commit to Gitea agent.
|
||||
|
||||
## Before calling any script, verify
|
||||
|
||||
- The SOPS vault is accessible via `.claude/identity.json` `vault_path` field. The scripts auto-resolve the vault location from identity.json — no hardcoded paths.
|
||||
- `jq`, `curl`, `bash` are available.
|
||||
- For Exchange REST checks: confirm the target tenant has **Exchange Administrator** role assigned to the **Security Investigator** SP (for reads) or **Exchange Operator** SP (for writes). If any Exchange REST call returns 403, emit the tenant-scoped Entra Roles link from `references/gotchas.md`.
|
||||
- For Identity Protection checks: `IdentityRiskyUser.Read.All` is in the Security Investigator manifest AND the tenant has consented to that app. If 403, emit the per-app consent URL from `references/gotchas.md`.
|
||||
- For Defender checks: confirm tenant has Microsoft Defender for Endpoint (MDE) license before using `defender` tier — it returns AADSTS650052 otherwise.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Target identifiers**: accept UPN, domain, or tenant GUID. Normalize to tenant GUID internally.
|
||||
- **Token tiers**: minimum necessary privilege. Never use `tenant-admin` for a read-only check.
|
||||
- **Token cache**: `/tmp/remediation-tool/{tenant-id}/{tier}.jwt`. TTL 55 minutes. Check `-mmin -55` before reuse.
|
||||
- **Raw JSON artifacts**: `/tmp/remediation-tool/{tenant-id}/{check}/` — keep so the user can re-analyze.
|
||||
- **Reports**: `clients/{slug}/reports/YYYY-MM-DD-{action}.md`. Derive slug from domain (strip TLD, hyphenate).
|
||||
- **UTC dates everywhere**.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
- **Not a replacement for CIPP.** Use CIPP for bulk baseline configuration, templates, standards alerting. Use this tool for focused investigation and point-in-time remediation.
|
||||
- **Entra app registrations stay manual in the portal** — don't create/modify the multi-tenant apps themselves via the tool.
|
||||
- **Conditional Access policies CAN be managed programmatically** (Tenant Admin tier holds `Policy.ReadWrite.ConditionalAccess` + the Conditional Access Administrator role). MANDATORY discipline: (1) always create/modify in **report-only** (`state: enabledForReportingButNotEnforced`) first; (2) always **exclude the tenant's break-glass account** (`conditions.users.excludeUsers`); (3) verify impact in Entra sign-in logs before enforcing; (4) get explicit user confirmation before flipping any policy to `enabled` on a tenant with real users. (CA-manual boundary relaxed 2026-05-27 at Mike's direction — break-glass + report-only keep blast radius near zero.)
|
||||
- **Not for Graph permissions the apps don't have.** If a call 403s and the scope isn't in the relevant app's manifest, stop and tell the user — don't try to work around it.
|
||||
- **Defender tier requires MDE license.** If the tenant doesn't have MDE, the token request succeeds but API calls return AADSTS650052. Check before using.
|
||||
|
||||
@@ -5,6 +5,14 @@ Last updated: 2026-04-20. Source of truth: CIPP ListTenants API.
|
||||
Run `bash scripts/onboard-tenant.sh <domain>` after any tenant consents Tenant Admin.
|
||||
After full onboarding, update the Onboarded column below.
|
||||
|
||||
**Exchange access (recurring gap — now closed):** EXO management (audit log, message trace, inbox
|
||||
rules) needs the **Exchange Operator SP** to hold the **Exchange Administrator** directory role, which
|
||||
admin consent does NOT grant. Onboarding assigns it, but tenants consented before that step / by hand
|
||||
were missing it. Fleet **backfilled 2026-06-08** (13 stragglers fixed). **Standing audit:** run
|
||||
`bash scripts/assign-exchange-role.sh --all --verify` periodically — any `WOULD assign` is a tenant
|
||||
that will fail the next email task; fix it with `assign-exchange-role.sh <domain>`. See
|
||||
[[feedback_exchange_role_recurring_gap]].
|
||||
|
||||
## Tenant List
|
||||
|
||||
| Display Name | Domain | Tenant ID | Onboarded | Notes |
|
||||
@@ -29,7 +37,7 @@ After full onboarding, update the Onboarded column below.
|
||||
| Jema Enterprises, LLC | jemaenterprises.com | 41268042-9a8e-41c2-9a3c-0775398b86cb | NO | |
|
||||
| JR Kennedy Company | jrkco.com | a92594b9-c8ad-4dba-8b40-14fcd32c723c | NO | |
|
||||
| Khalsa Montessori School | khalsamontessorischools.onmicrosoft.com | b2950f9d-81f8-40e4-85d9-2854d1d4f31b | NO | |
|
||||
| Kittle Design & Construction | kittlearizona.com | 3d073ebe-806a-4a5e-9035-3c7c4a264fc0 | PARTIAL | Sec Inv consented 2026-04-23; Exchange Admin role NOT assigned; Tenant Admin not consented; breach check run — Alexis + Ken inbox rules flagged |
|
||||
| Kittle Design & Construction | kittlearizona.com | 3d073ebe-806a-4a5e-9035-3c7c4a264fc0 | YES | Sec Inv + Exchange Operator + Tenant Admin consented (2026-06-08 BEC remediation). Exchange Admin role IS assigned to Exch Op SP (verified 2026-06-09 — prior "NOT assigned" note was stale). BEC EXO persistence re-verified clean 2026-06-09: malicious inbox rules gone, no forwarding, no transport rules, no rogue delegates. Open (need Ken): "Christina Micek" StopProcessing rule on Ken + Ken FullAccess to Accounting. |
|
||||
| LeeAnn Parkinson | lamaddux.com | 2f0c4c92-c608-4ee0-bdc2-87d5fd8fe929 | NO | |
|
||||
| Marty Ryan | martylryan.com | 48581923-2153-48b9-82b3-6a3587813041 | YES | Sec Inv + Tenant Admin consented; all roles assigned 2026-04-20 |
|
||||
| MVAN Enterprises, Inc | mvan.onmicrosoft.com | 5affaf1e-de89-416b-a655-1b2cf615d5b1 | NO | |
|
||||
@@ -41,7 +49,7 @@ After full onboarding, update the Onboarded column below.
|
||||
| Ridgetop Group | ridgetopgroup.com | ef111bfc-9c90-43c9-a581-f9bbfceb6517 | NO | |
|
||||
| Rincon Vista Veterinary Center | rinconvistavet.onmicrosoft.com | b8cdcd89-d0f4-4747-bcf3-8bd8a25fd7e1 | NO | |
|
||||
| Russo Law Firm | rrs-law.com | bef1b190-f78f-4b1c-aa4b-fab186a30702 | NO | |
|
||||
| Safe Site Utility Services LLC | safesitellc.com | 71b4e637-c802-4137-a812-ae50dbc839e3 | NO | |
|
||||
| Safe Site Utility Services LLC | safesitellc.com | 71b4e637-c802-4137-a812-ae50dbc839e3 | YES | Graph tiers consented (Sec Investigator + User Manager + Tenant Admin), verified live 2026-06-08. Exchange Admin role / MDE not yet verified. |
|
||||
| SANDTEKO MACHINERY LLC | SANDTEKOMACHINERY.com | 739bb777-cf76-478f-866b-f61c830c8246 | YES | All apps consented 2026-04-24; Sec Inv + Exch Op Exchange Admin + User Mgr User Admin + Auth Admin roles assigned; no MDE |
|
||||
| Shave, Kevin | az2son.com | 984c05a9-708b-4ec1-9f43-558865cb3c9d | NO | |
|
||||
| Sonorangreenllc.com | sonorangreenllc.com | ededa4fb-f6eb-4398-851d-5eb3e11fab27 | NO | |
|
||||
@@ -52,6 +60,7 @@ After full onboarding, update the Onboarded column below.
|
||||
| Tucson Mountain Motors | tucsonmountainmotors.com | ffdabd05-236b-4666-a7f5-cc40ae9f9122 | NO | |
|
||||
| Valley Wide Plastering | valleywideplastering.com | 5c53ae9f-7071-4248-b834-8685b646450f | NO | Old app only |
|
||||
| Von's Carstar | vonscarstar.com | 53de51b9-a063-4f46-88ff-7c3468828ed9 | NO | |
|
||||
| Wolkin, Robert | rswolkin.com | ceb6dbe7-82c8-4d8f-9c6b-49aa26208e9b | YES | All apps consented + roles assigned 2026-06-05 (Tenant Admin CA Admin; Sec Inv + Exch Op Exchange Admin; User Mgr User Admin + Auth Admin); no MDE; 2 users |
|
||||
|
||||
## Tenant Admin Consent URLs (batch)
|
||||
|
||||
|
||||
106
.claude/skills/remediation-tool/scripts/assign-exchange-role.sh
Normal file
106
.claude/skills/remediation-tool/scripts/assign-exchange-role.sh
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# assign-exchange-role.sh — assign the Entra "Exchange Administrator" directory role to the
|
||||
# ComputerGuru Exchange Operator service principal in a customer tenant.
|
||||
#
|
||||
# WHY THIS EXISTS: app-only Exchange Online management (Search-UnifiedAuditLog, Get-MessageTrace,
|
||||
# Get/Remove-InboxRule, Set-Mailbox, mailbox forwarding/delegate audit) requires the app's SP to
|
||||
# hold BOTH the `Exchange.ManageAsApp` API permission (granted by admin consent) AND an Entra
|
||||
# **directory role** (Exchange Administrator). Admin consent grants the API permission but NEVER
|
||||
# the directory role — so every freshly-consented tenant 401/403s on EXO management until this one
|
||||
# step is done. This script closes that gap, idempotently, and is wired into onboard-tenant.sh so
|
||||
# new tenants get it automatically. Run `--all` to backfill the existing fleet.
|
||||
#
|
||||
# Usage:
|
||||
# assign-exchange-role.sh <domain-or-tenant-id> assign for one tenant
|
||||
# assign-exchange-role.sh --all every tenant in references/tenants.md
|
||||
# assign-exchange-role.sh <target|--all> --verify report current state only (no writes)
|
||||
# assign-exchange-role.sh <target|--all> --dry-run show what WOULD change (no writes)
|
||||
#
|
||||
# Requires: the tenant-admin app consented in the target tenant (it carries
|
||||
# RoleManagement.ReadWrite.Directory). Tenants where tenant-admin or the Exchange Operator app is
|
||||
# not consented are SKIPPED with a clear reason (not an error).
|
||||
#
|
||||
# Read-only by default? NO — without --verify/--dry-run it performs the role assignment (a security
|
||||
# change). It is idempotent: a tenant already assigned is reported and left untouched.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
EXCHANGE_OP_APPID="b43e7342-5b4b-492f-890f-bb5a4f7f40e9" # ComputerGuru Exchange Operator
|
||||
EXCH_ADMIN_TEMPLATE="29232cdf-9323-42fd-ade2-1d097af3e4de" # Entra "Exchange Administrator" roleTemplateId
|
||||
GRAPH="https://graph.microsoft.com/v1.0"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GET_TOKEN="$SCRIPT_DIR/get-token.sh"
|
||||
TENANTS_MD="$SCRIPT_DIR/../references/tenants.md"
|
||||
|
||||
# Resolve vault_path -> VAULT_ROOT_ENV so get-token.sh works regardless of ~/.claude/identity.json.
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
if [ -z "${VAULT_ROOT_ENV:-}" ]; then
|
||||
for idf in "$REPO_ROOT/.claude/identity.json" "$HOME/.claude/identity.json"; do
|
||||
[ -f "$idf" ] || continue
|
||||
vp="$(jq -r '.vault_path // empty' "$idf" 2>/dev/null)"
|
||||
[ -n "$vp" ] && { export VAULT_ROOT_ENV="$vp"; break; }
|
||||
done
|
||||
fi
|
||||
|
||||
MODE="apply"
|
||||
TARGET=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--verify) MODE="verify" ;;
|
||||
--dry-run) MODE="dryrun" ;;
|
||||
--all) TARGET="--all" ;;
|
||||
-h|--help) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) TARGET="$a" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$TARGET" ] || { echo "[ERROR] need a tenant (domain/id) or --all. See --help." >&2; exit 64; }
|
||||
|
||||
jqr() { jq -r "$1" 2>/dev/null | tr -d '\r'; }
|
||||
gget() { curl -s --max-time 25 -H "Authorization: Bearer $1" "$2" | tr -d '\000'; }
|
||||
|
||||
# process_one <domain-or-tenant-id>
|
||||
process_one() {
|
||||
local tgt="$1" tok sp_id role_id members present rc body
|
||||
printf '%-42s ' "$tgt"
|
||||
|
||||
tok="$(VAULT_ROOT_ENV="${VAULT_ROOT_ENV:-}" bash "$GET_TOKEN" "$tgt" tenant-admin 2>/dev/null | tr -d '[:space:]')"
|
||||
if [ -z "$tok" ] || [ "${#tok}" -lt 100 ]; then echo "SKIP (tenant-admin not consented)"; return; fi
|
||||
|
||||
sp_id="$(gget "$tok" "$GRAPH/servicePrincipals?\$filter=appId%20eq%20'$EXCHANGE_OP_APPID'&\$select=id" | jqr '.value[0].id // empty')"
|
||||
if [ -z "$sp_id" ]; then echo "SKIP (Exchange Operator app not consented in tenant)"; return; fi
|
||||
|
||||
# Use the AUTHORITATIVE unified role-assignment API (roleManagement/directory/roleAssignments)
|
||||
# for both the idempotency check and the write. The legacy directoryRoles/{id}/members list
|
||||
# reads back unreliably (replication lag) and falsely reports not-assigned; roleAssignments is
|
||||
# consistent. For built-in roles, roleDefinitionId == the roleTemplateId.
|
||||
present="$(gget "$tok" "$GRAPH/roleManagement/directory/roleAssignments?\$filter=principalId%20eq%20'$sp_id'%20and%20roleDefinitionId%20eq%20'$EXCH_ADMIN_TEMPLATE'" | jqr '.value | length')"
|
||||
if [ "${present:-0}" -gt 0 ] 2>/dev/null; then echo "OK (already assigned)"; return; fi
|
||||
|
||||
if [ "$MODE" != "apply" ]; then echo "WOULD assign Exchange Admin to SP $sp_id"; return; fi
|
||||
|
||||
rc="$(curl -s --max-time 25 -o /tmp/aer_resp.$$ -w '%{http_code}' -X POST "$GRAPH/roleManagement/directory/roleAssignments" \
|
||||
-H "Authorization: Bearer $tok" -H "Content-Type: application/json" \
|
||||
-d "{\"principalId\":\"$sp_id\",\"roleDefinitionId\":\"$EXCH_ADMIN_TEMPLATE\",\"directoryScopeId\":\"/\"}")"
|
||||
body="$(tr -d '\000' </tmp/aer_resp.$$ 2>/dev/null)"; rm -f /tmp/aer_resp.$$ 2>/dev/null
|
||||
case "$rc" in
|
||||
201) echo "ASSIGNED (Exchange Admin -> Exchange Operator SP)" ;;
|
||||
400) if echo "$body" | grep -qiE 'conflicting object|already (exist|present)'; then echo "OK (already assigned)"
|
||||
else echo "ERROR (HTTP 400: $(echo "$body" | jqr '.error.message // .' | head -c 120))"; fi ;;
|
||||
*) echo "ERROR (HTTP $rc: $(echo "$body" | jqr '.error.message // .' | head -c 120))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "=== assign-exchange-role [mode=$MODE] ==="
|
||||
echo "Role: Exchange Administrator ($EXCH_ADMIN_TEMPLATE) -> SP: Exchange Operator ($EXCHANGE_OP_APPID)"
|
||||
echo "------------------------------------------------------------------------"
|
||||
if [ "$TARGET" = "--all" ]; then
|
||||
[ -f "$TENANTS_MD" ] || { echo "[ERROR] tenants.md not found: $TENANTS_MD" >&2; exit 66; }
|
||||
# extract tenant GUIDs from the markdown table (column 3)
|
||||
grep -oE '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' "$TENANTS_MD" \
|
||||
| sort -u | while read -r tid; do process_one "$tid"; done
|
||||
else
|
||||
process_one "$TARGET"
|
||||
fi
|
||||
echo "------------------------------------------------------------------------"
|
||||
echo "Done. (Re-run with --verify any time to audit fleet state.)"
|
||||
@@ -324,11 +324,12 @@ consent_app() {
|
||||
}
|
||||
|
||||
# ── Helper: check if directory role already assigned ─────────────────────────
|
||||
# TODO(howard): This only checks roleAssignments (direct/permanent). PIM-managed
|
||||
# assignments live in roleAssignmentSchedules and won't be found here, causing
|
||||
# noisy-but-harmless "MISSING -> ASSIGNING" output that hits the Conflict fallback.
|
||||
# Fix: also query /roleManagement/directory/roleAssignmentSchedules?$filter=principalId eq '...'
|
||||
# and return true if either query finds the role. Reference: Howard's note 2026-04-29.
|
||||
# NOTE: The "MISSING -> ASSIGNING" noise was NOT PIM, as previously suspected — the
|
||||
# root cause was an unencoded space in the $filter (now %20-encoded), which made Graph
|
||||
# return empty/error and this function always return false. The ACG tenant has no Entra
|
||||
# ID P2, so PIM is not a factor here. The dual-query idea (also checking
|
||||
# /roleManagement/directory/roleAssignmentSchedules) remains valid ONLY for P2 tenants
|
||||
# where roles can be PIM-managed; return true if either query finds the role.
|
||||
role_assigned() {
|
||||
local token="$1"
|
||||
local sp_oid="$2"
|
||||
@@ -336,7 +337,7 @@ role_assigned() {
|
||||
local resp
|
||||
resp=$(curl -s --max-time 15 \
|
||||
-H "Authorization: Bearer $token" \
|
||||
"https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$filter=principalId eq '${sp_oid}'")
|
||||
"https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$filter=principalId%20eq%20'${sp_oid}'")
|
||||
echo "$resp" | jq --arg rid "$role_id" \
|
||||
'[.value[] | select(.roleDefinitionId == $rid)] | length > 0'
|
||||
}
|
||||
|
||||
111
.claude/skills/remediation-tool/scripts/reset-password.sh
Normal file
111
.claude/skills/remediation-tool/scripts/reset-password.sh
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reset an M365 user's password via Graph (app-only, tenant-admin tier).
|
||||
#
|
||||
# Usage: reset-password.sh <tenant-id-or-domain> <upn> <new-password> [--force-change]
|
||||
# --force-change set forceChangePasswordNextSignIn=true (default: false / permanent)
|
||||
#
|
||||
# Why this script exists:
|
||||
# A plain PATCH of passwordProfile works for ordinary members, but Microsoft
|
||||
# protects admin-role holders: resetting the password of a user who holds a
|
||||
# directory role (e.g. SharePoint/Teams/User Administrator) requires the CALLER
|
||||
# to hold Global Administrator or Privileged Authentication Administrator. The
|
||||
# Tenant Admin app has User.ReadWrite.All but no standing directory role, so it
|
||||
# gets 403 on admin targets.
|
||||
#
|
||||
# This script does a JUST-IN-TIME elevation: if the direct reset 403s, it
|
||||
# assigns the Tenant Admin service principal the Privileged Authentication
|
||||
# Administrator role (the app already holds RoleManagement.ReadWrite.Directory),
|
||||
# retries the reset, then REMOVES the role assignment it created. No standing
|
||||
# super-privilege is left behind. If the SP already held the role, it is left
|
||||
# untouched.
|
||||
#
|
||||
# Output: human-readable status to stdout. Exit 0 on success.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
TENANT_INPUT="${1:?usage: reset-password.sh <tenant|domain> <upn> <new-password> [--force-change]}"
|
||||
UPN="${2:?usage: reset-password.sh <tenant|domain> <upn> <new-password> [--force-change]}"
|
||||
NEWPW="${3:?usage: reset-password.sh <tenant|domain> <upn> <new-password> [--force-change]}"
|
||||
FORCE_CHANGE="false"
|
||||
[[ "${4:-}" == "--force-change" ]] && FORCE_CHANGE="true"
|
||||
|
||||
# Privileged Authentication Administrator (built-in role template / definition id)
|
||||
PAA_ROLE_ID="7be44c8a-adaf-4e2a-84d6-ab2649e08a13"
|
||||
TENANT_ADMIN_APPID="709e6eed-0711-4875-9c44-2d3518c47063"
|
||||
|
||||
TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TENANT_INPUT")
|
||||
TOKEN=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" tenant-admin)
|
||||
|
||||
GH=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json")
|
||||
G="https://graph.microsoft.com/v1.0"
|
||||
|
||||
# --- resolve target user object id ---
|
||||
UID_=$(curl -s "${GH[@]}" "$G/users/${UPN}?\$select=id" | tr -d '\000-\037' \
|
||||
| python -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
[[ -z "$UID_" ]] && { echo "[ERROR] user not found: $UPN" >&2; exit 1; }
|
||||
echo "[info] tenant=$TENANT_ID target=$UPN id=$UID_ force_change=$FORCE_CHANGE"
|
||||
|
||||
# --- build payload (single-quoted heredoc would block $NEWPW; use python to emit JSON safely) ---
|
||||
PAYLOAD=$(NEWPW="$NEWPW" FC="$FORCE_CHANGE" python -c "import os,json;print(json.dumps({'passwordProfile':{'password':os.environ['NEWPW'],'forceChangePasswordNextSignIn':os.environ['FC']=='true'}}))")
|
||||
|
||||
do_patch() {
|
||||
curl -s -o /dev/null -w "%{http_code}" -X PATCH "${GH[@]}" "$G/users/$UID_" --data-binary "$PAYLOAD"
|
||||
}
|
||||
|
||||
CODE=$(do_patch)
|
||||
if [[ "$CODE" == "204" ]]; then
|
||||
echo "[OK] password reset for $UPN (no elevation needed)"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$CODE" != "403" ]]; then
|
||||
echo "[ERROR] unexpected HTTP $CODE on password PATCH" >&2
|
||||
curl -s -X PATCH "${GH[@]}" "$G/users/$UID_" --data-binary "$PAYLOAD" | tr -d '\000-\037' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[info] 403 on direct reset (target likely holds an admin role) -> JIT elevation"
|
||||
|
||||
# --- resolve tenant-admin SP object id ---
|
||||
SPID=$(curl -s "${GH[@]}" "$G/servicePrincipals(appId='$TENANT_ADMIN_APPID')?\$select=id" | tr -d '\000-\037' \
|
||||
| python -c "import sys,json;print(json.load(sys.stdin).get('id',''))")
|
||||
[[ -z "$SPID" ]] && { echo "[ERROR] could not resolve Tenant Admin service principal" >&2; exit 1; }
|
||||
|
||||
# --- does the SP already hold Privileged Authentication Administrator? ---
|
||||
EXISTING=$(curl -s "${GH[@]}" "$G/roleManagement/directory/roleAssignments?\$filter=principalId+eq+'$SPID'+and+roleDefinitionId+eq+'$PAA_ROLE_ID'" \
|
||||
| tr -d '\000-\037' | python -c "import sys,json;v=json.load(sys.stdin).get('value',[]);print(v[0]['id'] if v else '')" 2>/dev/null || true)
|
||||
|
||||
CREATED_ASSIGNMENT=""
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
echo "[info] SP already holds Privileged Authentication Administrator (standing) -> not modifying role"
|
||||
else
|
||||
ASSIGN_BODY=$(SPID="$SPID" RID="$PAA_ROLE_ID" python -c "import os,json;print(json.dumps({'principalId':os.environ['SPID'],'roleDefinitionId':os.environ['RID'],'directoryScopeId':'/'}))")
|
||||
CREATED_ASSIGNMENT=$(curl -s -X POST "${GH[@]}" "$G/roleManagement/directory/roleAssignments" --data-binary "$ASSIGN_BODY" \
|
||||
| tr -d '\000-\037' | python -c "import sys,json;d=json.load(sys.stdin);print(d.get('id',''))" 2>/dev/null || true)
|
||||
[[ -z "$CREATED_ASSIGNMENT" ]] && { echo "[ERROR] failed to assign Privileged Authentication Administrator to SP" >&2; exit 1; }
|
||||
echo "[info] assigned Privileged Authentication Administrator to SP (assignment $CREATED_ASSIGNMENT)"
|
||||
fi
|
||||
|
||||
# --- de-elevation runs no matter how we exit, but only removes what WE created ---
|
||||
cleanup() {
|
||||
if [[ -n "$CREATED_ASSIGNMENT" ]]; then
|
||||
DC=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "${GH[@]}" "$G/roleManagement/directory/roleAssignments/$CREATED_ASSIGNMENT")
|
||||
if [[ "$DC" == "204" ]]; then echo "[info] removed JIT role assignment (de-elevated)"; else echo "[WARNING] failed to remove JIT role assignment $CREATED_ASSIGNMENT (HTTP $DC) - REMOVE MANUALLY" >&2; fi
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- retry the reset; role propagation can take a few seconds ---
|
||||
for i in 1 2 3 4 5 6; do
|
||||
sleep 10
|
||||
CODE=$(do_patch)
|
||||
if [[ "$CODE" == "204" ]]; then
|
||||
echo "[OK] password reset for $UPN (via JIT Privileged Authentication Administrator)"
|
||||
exit 0
|
||||
fi
|
||||
echo "[info] attempt $i: HTTP $CODE (waiting for role propagation)"
|
||||
done
|
||||
|
||||
echo "[ERROR] password reset still failing after elevation (last HTTP $CODE)" >&2
|
||||
curl -s -X PATCH "${GH[@]}" "$G/users/$UID_" --data-binary "$PAYLOAD" | tr -d '\000-\037' >&2
|
||||
exit 1
|
||||
@@ -1,161 +1,173 @@
|
||||
---
|
||||
name: self-check
|
||||
description: >-
|
||||
Self-diagnose a ClaudeTools session's machine: verify the harness is wired the
|
||||
same way as every other instance while allowing for architectural / OS / hardware
|
||||
differences. Checks that identity.json exists and is correct (the map of WHERE
|
||||
things live on this box), required tooling is installed, env/paths resolve,
|
||||
hooks are wired, the skill/command/script set matches the baseline, the vault
|
||||
decrypts, coord/Gitea are reachable, and the machine's capability tier (e.g. no
|
||||
local Ollama) resolves to the right fallback ruleset. Grades RED/AMBER/GREEN and
|
||||
can publish a census to the coord API so the fleet baseline can be built/refined.
|
||||
Invoke for: "self check", "self diagnosis", "self test", "doctor", "health check",
|
||||
"am I configured right", "is my machine set up correctly", "harness conformance",
|
||||
"fleet conformance", "check my environment", "is everything wired up".
|
||||
---
|
||||
|
||||
# Self-Check — ClaudeTools Harness Self-Diagnosis
|
||||
|
||||
A top-to-bottom evaluation of how *this* machine's ClaudeTools harness is wired,
|
||||
graded against a checked-in **baseline manifest** so every machine behaves the
|
||||
same way — while explicitly allowing for architecture, OS, and hardware
|
||||
differences via a **capability tier** model.
|
||||
|
||||
This is the skill the user asked for when a session needs to "make sure
|
||||
everything is as it should be."
|
||||
|
||||
## The model in one paragraph
|
||||
|
||||
`identity.json` is the foundational, per-machine map of **where things live and
|
||||
what this box can do** (vault path, repo root, platform, arch, python command,
|
||||
Ollama endpoints). The **baseline manifest**
|
||||
(`baseline/manifest.json`) declares what *every* machine must have — required
|
||||
tools, identity fields, scripts, hook files, the wired `settings.json` hooks, the
|
||||
canonical skill/command set, and the **capability rules** that say what to do when
|
||||
a capability is absent (e.g. no local Ollama → use the remote endpoint, or if that
|
||||
is also down, route Tier-0 work to haiku instead of blocking). The probe compares
|
||||
the live machine against the manifest, resolves the machine's capability tier, and
|
||||
grades RED/AMBER/GREEN. Required things missing = RED. Advisory drift = AMBER.
|
||||
Capability differences are **never** failures — they select a ruleset.
|
||||
|
||||
## V1 is a CENSUS tool (read this)
|
||||
|
||||
There is no ratified fleet baseline yet. `baseline/manifest.json` is **provisional**,
|
||||
generated from a single known-good machine (GURU-5070). So V1's job is to gather
|
||||
ground truth from every machine and help Mike build the real baseline:
|
||||
|
||||
1. **Probe** — each machine runs the check and produces a structured census.
|
||||
2. **Publish** — `--publish` PUTs the census to coord as component
|
||||
`selfcheck_<host>` (state = grade, notes = full JSON). One row per machine =
|
||||
a live fleet conformance view.
|
||||
3. **Fan out** — `fanout` broadcasts a request to `ALL_SESSIONS` so every active
|
||||
instance reports.
|
||||
4. **Aggregate** — `aggregate` reads all censuses back and proposes a baseline
|
||||
(tools/skills/commands present on *all* machines = "required everywhere";
|
||||
present on *some* = "capability-gated"), and lists machines with FAILs.
|
||||
|
||||
Mike reviews the aggregate and ratifies `manifest.json`. From then on the same
|
||||
probe enforces conformance. **V1 does not auto-fix anything** — it reports the
|
||||
exact fix command for each finding (per the decision on record).
|
||||
|
||||
## Running it
|
||||
|
||||
The probe is `scripts/self-check.sh` (bash; runs on Git Bash/Windows, macOS,
|
||||
Linux; deps: jq + curl). Always pass a real UTC timestamp:
|
||||
|
||||
```bash
|
||||
SELFCHECK_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
bash .claude/skills/self-check/scripts/self-check.sh <mode>
|
||||
```
|
||||
|
||||
| Mode | Purpose |
|
||||
|------|---------|
|
||||
| `report` (default) | Human RED/AMBER/GREEN report. Exit 0/1/2 = GREEN/AMBER/RED. |
|
||||
| `--json` | Structured census JSON to stdout (for piping). |
|
||||
| `--publish` | Run + publish census to coord (component `selfcheck_<host>`). Softfails to `.claude/coord-queue.jsonl`. |
|
||||
| `fanout` | Broadcast a census request to ALL_SESSIONS. |
|
||||
| `aggregate` | Fleet table + proposed-baseline summary from published censuses. |
|
||||
|
||||
`/self-check` is the slash-command runner for the same script.
|
||||
|
||||
## What it checks
|
||||
|
||||
| Category | Checks |
|
||||
|----------|--------|
|
||||
| **identity** | identity.json exists + valid JSON; **all required fields present**; `claudetools_root` exists and equals the running repo; `vault_path` exists; `machine` == hostname; git user.name/email match identity. |
|
||||
| **tooling** | required everywhere: bash, git, jq, curl, sops, age, ssh, a python. Missing = FAIL. |
|
||||
| **capability** | ollama, cargo, node, gh, docker, op — presence is INFO, never a failure. Resolves the **Ollama tier** (local / remote / none) and prints the effective Tier-0 ruleset. |
|
||||
| **files** | required scripts + hook files present and executable. |
|
||||
| **hooks** | the three `settings.json` hooks are wired (block-backslash PreToolUse, check-messages UserPromptSubmit, sync-memory SessionStart); `current-mode` present. |
|
||||
| **git** | origin points at ACG Gitea (internal IP preferred); main-repo post-commit hook installed (AMBER if not). |
|
||||
| **skills/commands** | every skill dir and command file in the baseline is present; extras are reported as census candidates. |
|
||||
| **duplicates** | command/skill names present in BOTH the repo and `~/.claude`. Divergent content = WARN (the "same `/cmd`, different behaviour on the Mac" bug); identical = INFO (redundant, will drift). CRLF-only differences are ignored. |
|
||||
| **memory** | `MEMORY.md` index exists; no orphaned memory files; manifest-declared contradiction patterns (see semantic pass below). Never FAILs the grade. |
|
||||
| **vault** | vault repo exists; sops+age present; `vault.sh list` succeeds (decrypt wired). |
|
||||
| **connectivity** | coord API (required), main API + internal Gitea (advisory; off-network is OK). |
|
||||
|
||||
## Rogue-memory contradiction — semantic pass (do this when asked, or on a full check)
|
||||
|
||||
The engine's memory check is deterministic and conservative (index + orphans +
|
||||
declared patterns) so it never produces false alarms. A *true* contradiction
|
||||
check — "does any memory directly contradict what this machine's settings say?"
|
||||
— is a judgment task, so the model does it (route the prose/classification to
|
||||
Ollama Tier-0 per the house rules; Claude reviews the result):
|
||||
|
||||
1. Read `identity.json` (where things live + this box's capabilities),
|
||||
`settings.json` (wired hooks/permissions), and `baseline/manifest.json`.
|
||||
2. Read the memory index `.claude/memory/MEMORY.md`, then open any memory whose
|
||||
one-line hook touches: paths/roots, python launcher, endpoints/IPs, OS/arch
|
||||
assumptions, tool choices, or model routing.
|
||||
3. Flag memories that **directly contradict** this machine's reality, e.g.:
|
||||
- prescribes `python3`/`python` when `identity.python.command` is `py` (or vice-versa),
|
||||
- hardcodes a repo/vault path that isn't this machine's `claudetools_root`/`vault_path`,
|
||||
- names an endpoint/IP that conflicts with `identity.coord_api` or the manifest,
|
||||
- assumes a capability (local Ollama) this machine's tier says is absent.
|
||||
4. Report each as: memory file, the contradicting claim, the setting it violates,
|
||||
and a suggested correction. **Do not edit memories** — surface for the operator
|
||||
(deletions/rewrites go through the human, mirroring memory-dream's posture).
|
||||
|
||||
Genuinely machine-specific guidance in a *shared* memory is the usual culprit —
|
||||
the fix is to scope it ("on Windows…") or split it, not to globally flip it.
|
||||
|
||||
## Fleet self-remediation loop (machines fix themselves)
|
||||
|
||||
We never fix a remote machine. The flow is:
|
||||
|
||||
1. `fanout` — broadcast asks every instance to self-check + self-fix + re-publish.
|
||||
2. Each operator runs `/self-check` locally, applies the printed fix commands on
|
||||
their own box, re-runs to confirm GREEN, then `/self-check --publish`.
|
||||
3. `aggregate` — shows who is still RED/AMBER and prints each machine's own fix
|
||||
list. Relay it to that operator; do not run it for them.
|
||||
4. Repeat until the fleet is consistently GREEN, then ratify the manifest.
|
||||
|
||||
## How to interpret a run
|
||||
|
||||
After running, summarize for the user:
|
||||
- The **grade** and the PASS/WARN/FAIL/INFO tallies.
|
||||
- Each **FAIL** and **WARN** with its exact fix command. Do not auto-apply.
|
||||
- The **capability tier** line — confirm the machine knows its Tier-0 fallback.
|
||||
- If publishing/aggregating, note how many machines have reported and which are RED.
|
||||
|
||||
Capability differences (no Ollama, no gh, ARM vs amd64, macOS vs Windows) are
|
||||
expected and must never be reported as broken — they are the whole point of the
|
||||
tier model.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
.claude/skills/self-check/
|
||||
SKILL.md this file
|
||||
scripts/self-check.sh the probe engine (report / --json / --publish / fanout / aggregate)
|
||||
baseline/manifest.json the provisional fleet baseline (single source of truth)
|
||||
baseline/README.md the baseline model + how to refine/ratify it
|
||||
.claude/commands/self-check.md the /self-check runner
|
||||
```
|
||||
|
||||
## Extending the baseline
|
||||
|
||||
When a new tool/skill/command/hook becomes mandatory fleet-wide, edit
|
||||
`baseline/manifest.json`, commit, and `/sync`. Every machine's next self-check
|
||||
enforces it. Capability-only tools go in `capability_tools` with a matching entry
|
||||
in `capability_rules` describing the fallback. See `baseline/README.md`.
|
||||
---
|
||||
name: self-check
|
||||
description: "Self-diagnose this machine's harness conformance vs the fleet baseline: identity.json, tooling, env/paths, hooks, skill/command/script set, vault decrypt, coord/Gitea reachability, capability tier. Grades RED/AMBER/GREEN; can publish a census. Triggers: self check/test, doctor, health check, am I configured right, harness/fleet conformance."
|
||||
|
||||
---
|
||||
|
||||
# Self-Check — ClaudeTools Harness Self-Diagnosis
|
||||
|
||||
A top-to-bottom evaluation of how *this* machine's ClaudeTools harness is wired,
|
||||
graded against a checked-in **baseline manifest** so every machine behaves the
|
||||
same way — while explicitly allowing for architecture, OS, and hardware
|
||||
differences via a **capability tier** model.
|
||||
|
||||
This is the skill the user asked for when a session needs to "make sure
|
||||
everything is as it should be."
|
||||
|
||||
## The model in one paragraph
|
||||
|
||||
`identity.json` is the foundational, per-machine map of **where things live and
|
||||
what this box can do** (vault path, repo root, platform, arch, python command,
|
||||
Ollama endpoints). The **baseline manifest**
|
||||
(`baseline/manifest.json`) declares what *every* machine must have — required
|
||||
tools, identity fields, scripts, hook files, the wired `settings.json` hooks, the
|
||||
canonical skill/command set, and the **capability rules** that say what to do when
|
||||
a capability is absent (e.g. no local Ollama → use the remote endpoint, or if that
|
||||
is also down, route Tier-0 work to haiku instead of blocking). The probe compares
|
||||
the live machine against the manifest, resolves the machine's capability tier, and
|
||||
grades RED/AMBER/GREEN. Required things missing = RED. Advisory drift = AMBER.
|
||||
Capability differences are **never** failures — they select a ruleset.
|
||||
|
||||
## V1 is a CENSUS tool (read this)
|
||||
|
||||
There is no ratified fleet baseline yet. `baseline/manifest.json` is **provisional**,
|
||||
generated from a single known-good machine (GURU-5070). So V1's job is to gather
|
||||
ground truth from every machine and help Mike build the real baseline:
|
||||
|
||||
1. **Probe** — each machine runs the check and produces a structured census.
|
||||
2. **Publish** — `--publish` PUTs the census to coord as component
|
||||
`selfcheck_<host>` (state = grade, notes = full JSON). One row per machine =
|
||||
a live fleet conformance view.
|
||||
3. **Fan out** — `fanout` broadcasts a request to `ALL_SESSIONS` so every active
|
||||
instance reports.
|
||||
4. **Aggregate** — `aggregate` reads all censuses back and proposes a baseline
|
||||
(tools/skills/commands present on *all* machines = "required everywhere";
|
||||
present on *some* = "capability-gated"), and lists machines with FAILs.
|
||||
|
||||
Mike reviews the aggregate and ratifies `manifest.json`. From then on the same
|
||||
probe enforces conformance. **V1 does not auto-fix anything** — it reports the
|
||||
exact fix command for each finding (per the decision on record).
|
||||
|
||||
## Running it
|
||||
|
||||
The probe is `scripts/self-check.sh` (bash; runs on Git Bash/Windows, macOS,
|
||||
Linux; deps: jq + curl). Always pass a real UTC timestamp:
|
||||
|
||||
```bash
|
||||
SELFCHECK_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
bash .claude/skills/self-check/scripts/self-check.sh <mode>
|
||||
```
|
||||
|
||||
| Mode | Purpose |
|
||||
|------|---------|
|
||||
| `report` (default) | Human RED/AMBER/GREEN report. Exit 0/1/2 = GREEN/AMBER/RED. |
|
||||
| `--json` | Structured census JSON to stdout (for piping). |
|
||||
| `--publish` | Run + publish census to coord (component `selfcheck_<host>`). Softfails to `.claude/coord-queue.jsonl`. |
|
||||
| `fanout` | Broadcast a census request to ALL_SESSIONS. |
|
||||
| `aggregate` | Fleet table + proposed-baseline summary from published censuses. |
|
||||
|
||||
`/self-check` is the slash-command runner for the same script.
|
||||
|
||||
## What it checks
|
||||
|
||||
| Category | Checks |
|
||||
|----------|--------|
|
||||
| **identity** | identity.json exists + valid JSON; **all required fields present**; `claudetools_root` exists and equals the running repo; `vault_path` exists; `machine` == hostname; git user.name/email match identity. |
|
||||
| **tooling** | required everywhere: bash, git, jq, curl, sops, age, ssh, a python. Missing = FAIL. |
|
||||
| **capability** | ollama, cargo, node, gh, docker, op — presence is INFO, never a failure. Resolves the **Ollama tier** (local / remote / none) and prints the effective Tier-0 ruleset. |
|
||||
| **files** | required scripts + hook files present and executable. |
|
||||
| **hooks** | the three `settings.json` hooks are wired (block-backslash PreToolUse, check-messages UserPromptSubmit, sync-memory SessionStart); `current-mode` present. |
|
||||
| **git** | origin points at ACG Gitea (internal IP preferred); main-repo post-commit hook installed (AMBER if not). |
|
||||
| **skills/commands** | every skill dir and command file in the baseline is present; extras are reported as census candidates. |
|
||||
| **duplicates** | command/skill names present in BOTH the repo and `~/.claude`. Divergent content = WARN (the "same `/cmd`, different behaviour on the Mac" bug); identical = INFO (redundant, will drift). CRLF-only differences are ignored. |
|
||||
| **memory** | `MEMORY.md` index exists; no orphaned memory files; manifest-declared contradiction patterns (see semantic pass below). Never FAILs the grade. |
|
||||
| **harness** | the 1.4.0 invariants (read-only): VERSION marker present + not older than `manifest.harness.min_version`; **skill-registry description budget** (sum of all SKILL.md `description:` fields under `registry_desc_budget_chars` — WARN on regrowth); global deploy targets `~/.claude/skills` + `~/.claude/commands` populated (the "Mac wiped global skills" failure); `harness-guard.sh` present + wired into `sync.sh`; core scripts parse (`bash -n`); `now-phoenix.sh --date` emits a valid date; **guard self-test** runs the full `test-harness-guard.sh` false-positive/true-positive matrix in an isolated temp repo (proves the guard still catches real conflicts/secrets and does not false-positive — the standing prerequisite for promoting the guard to FATAL). Budget/min-version/script-list are tunable in `manifest.harness`. |
|
||||
| **consistency** | the **command-restates-standard** lint (deterministic half): for each `manifest.command_standard_links` pair, the standard must still contain its defer-to-SSOT pointer to the owning command. A lost pointer = WARN (the standard likely drifted back into restating the command — the Syncro-timers failure mode). The semantic contradiction judgement is delegated to the model (see below). |
|
||||
| **vault** | vault repo exists; sops+age present; `vault.sh list` succeeds (decrypt wired). |
|
||||
| **connectivity** | coord API (required), main API + internal Gitea (advisory; off-network is OK). |
|
||||
|
||||
## Rogue-memory contradiction — semantic pass (do this when asked, or on a full check)
|
||||
|
||||
The engine's memory check is deterministic and conservative (index + orphans +
|
||||
declared patterns) so it never produces false alarms. A *true* contradiction
|
||||
check — "does any memory directly contradict what this machine's settings say?"
|
||||
— is a judgment task, so the model does it (route the prose/classification to
|
||||
Ollama Tier-0 per the house rules; Claude reviews the result):
|
||||
|
||||
1. Read `identity.json` (where things live + this box's capabilities),
|
||||
`settings.json` (wired hooks/permissions), and `baseline/manifest.json`.
|
||||
2. Read the memory index `.claude/memory/MEMORY.md`, then open any memory whose
|
||||
one-line hook touches: paths/roots, python launcher, endpoints/IPs, OS/arch
|
||||
assumptions, tool choices, or model routing.
|
||||
3. Flag memories that **directly contradict** this machine's reality, e.g.:
|
||||
- prescribes `python3`/`python` when `identity.python.command` is `py` (or vice-versa),
|
||||
- hardcodes a repo/vault path that isn't this machine's `claudetools_root`/`vault_path`,
|
||||
- names an endpoint/IP that conflicts with `identity.coord_api` or the manifest,
|
||||
- assumes a capability (local Ollama) this machine's tier says is absent.
|
||||
4. Report each as: memory file, the contradicting claim, the setting it violates,
|
||||
and a suggested correction. **Do not edit memories** — surface for the operator
|
||||
(deletions/rewrites go through the human, mirroring memory-dream's posture).
|
||||
|
||||
Genuinely machine-specific guidance in a *shared* memory is the usual culprit —
|
||||
the fix is to scope it ("on Windows…") or split it, not to globally flip it.
|
||||
|
||||
### Semantic pass 2 — command vs standard contradiction
|
||||
|
||||
The `consistency` category only checks that the defer-to-SSOT *pointer* is present.
|
||||
Whether a command and its standard actually **say contradictory things** is a
|
||||
judgement task — do it the same way (Ollama Tier-0 for the read/classify, Claude
|
||||
reviews):
|
||||
|
||||
1. For each `manifest.command_standard_links` pair, read BOTH the standard and the
|
||||
owning command it points to.
|
||||
2. Flag any rule the standard states that **conflicts** with the command (e.g. the
|
||||
standard mandates a timer for routine billing while `/syncro` says line-item is
|
||||
normal and timers are outlier-only — the original drift this lint exists to catch).
|
||||
3. Report: the topic, the conflicting claims (quote both sides), and which one is the
|
||||
SSOT. **Do not edit** — surface for the operator; the SSOT (the command) wins, so
|
||||
the fix is almost always to correct the standard, not the command.
|
||||
|
||||
New links are cheap to add — drop another `{topic, standard, must_reference, why}`
|
||||
into `manifest.command_standard_links` whenever a command and a standard speak to the
|
||||
same rule.
|
||||
|
||||
## Fleet self-remediation loop (machines fix themselves)
|
||||
|
||||
We never fix a remote machine. The flow is:
|
||||
|
||||
1. `fanout` — broadcast asks every instance to self-check + self-fix + re-publish.
|
||||
2. Each operator runs `/self-check` locally, applies the printed fix commands on
|
||||
their own box, re-runs to confirm GREEN, then `/self-check --publish`.
|
||||
3. `aggregate` — shows who is still RED/AMBER and prints each machine's own fix
|
||||
list. Relay it to that operator; do not run it for them.
|
||||
4. Repeat until the fleet is consistently GREEN, then ratify the manifest.
|
||||
|
||||
## How to interpret a run
|
||||
|
||||
After running, summarize for the user:
|
||||
- The **grade** and the PASS/WARN/FAIL/INFO tallies.
|
||||
- Each **FAIL** and **WARN** with its exact fix command. Do not auto-apply.
|
||||
- The **capability tier** line — confirm the machine knows its Tier-0 fallback.
|
||||
- If publishing/aggregating, note how many machines have reported and which are RED.
|
||||
|
||||
Capability differences (no Ollama, no gh, ARM vs amd64, macOS vs Windows) are
|
||||
expected and must never be reported as broken — they are the whole point of the
|
||||
tier model.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
.claude/skills/self-check/
|
||||
SKILL.md this file
|
||||
scripts/self-check.sh the probe engine (report / --json / --publish / fanout / aggregate)
|
||||
baseline/manifest.json the provisional fleet baseline (single source of truth)
|
||||
baseline/README.md the baseline model + how to refine/ratify it
|
||||
.claude/commands/self-check.md the /self-check runner
|
||||
```
|
||||
|
||||
## Extending the baseline
|
||||
|
||||
When a new tool/skill/command/hook becomes mandatory fleet-wide, edit
|
||||
`baseline/manifest.json`, commit, and `/sync`. Every machine's next self-check
|
||||
enforces it. Capability-only tools go in `capability_tools` with a matching entry
|
||||
in `capability_rules` describing the fallback. See `baseline/README.md`.
|
||||
|
||||
@@ -5,6 +5,30 @@
|
||||
"derived_at": "2026-06-02",
|
||||
"note": "PROVISIONAL baseline, generated from a single known-good machine. V1 of self-check is a CENSUS tool: every machine probes itself, publishes to the coord API, and we refine this manifest from real fleet data (see baseline/README.md). Do NOT treat 'extra' or 'missing' items as authoritative until the fleet census has confirmed them across machines.",
|
||||
|
||||
"harness": {
|
||||
"min_version": "1.4.0",
|
||||
"version_file": ".claude/harness/VERSION",
|
||||
"registry_desc_budget_chars": 10500,
|
||||
"registry_desc_why": "Sum of all skill SKILL.md description: fields. These inject into EVERY session's skill registry, so a bloated description is a fleet-wide context tax. Budget set at the 1.4.0 post-trim size (~8.7k) + headroom; a WARN here means a skill description grew back and should be re-trimmed (move triggers/examples into the SKILL.md body).",
|
||||
"syntax_check_scripts": [
|
||||
".claude/scripts/sync.sh",
|
||||
".claude/scripts/harness-guard.sh",
|
||||
".claude/scripts/now-phoenix.sh",
|
||||
".claude/scripts/test-harness-guard.sh"
|
||||
],
|
||||
"guard_wired_in": ".claude/scripts/sync.sh"
|
||||
},
|
||||
|
||||
"command_standard_links": [
|
||||
{
|
||||
"topic": "syncro-billing",
|
||||
"standard": ".claude/standards/syncro/time-entry-protocol.md",
|
||||
"must_reference": "syncro\\.md|single source of truth",
|
||||
"why": "the time-entry standard must DEFER to the /syncro command (one SSOT), not restate billing mechanics. A past drift had the standard say 'always timer' while the command said 'outlier only' — losing the pointer is the early warning of that re-drift."
|
||||
}
|
||||
],
|
||||
"command_standard_links_note": "Deterministic half of the command-restates-standard lint: each linked standard must contain a defer-to-SSOT pointer (must_reference, a grep -iE regex). A WARN means the standard may have drifted back into restating/contradicting the command. The SEMANTIC contradiction judgement (read both files, decide if they actually conflict) is delegated to the model in SKILL.md, mirroring the memory contradiction pass.",
|
||||
|
||||
"required_tools": [
|
||||
{ "name": "bash", "why": "hooks, scripts, sync, vault wrapper" },
|
||||
{ "name": "git", "why": "repo + submodules + Gitea sync" },
|
||||
|
||||
@@ -546,6 +546,166 @@ check_memory() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CHECK: harness smoke tests (the 1.4.0 invariants).
|
||||
# Locks in the harness-optimization gains so they can't silently regress:
|
||||
# - VERSION marker present and not older than the manifest's min_version
|
||||
# - skill-registry description budget (a bloated description taxes EVERY session)
|
||||
# - global deploy targets populated (the "Mac wiped ~/.claude/skills" failure)
|
||||
# - guard wired into sync.sh + executable
|
||||
# - core scripts parse (bash -n) and now-phoenix.sh emits a valid date
|
||||
# All read-only / non-invasive: no commits, no pushes, only a parse + a clock read.
|
||||
# ---------------------------------------------------------------------------
|
||||
ver_ge() { # $1 >= $2 -> echoes 1/0 (portable dotted-numeric compare)
|
||||
awk -v a="$1" -v b="$2" 'BEGIN{
|
||||
na=split(a,A,"."); nb=split(b,B,"."); n=(na>nb?na:nb);
|
||||
for(i=1;i<=n;i++){x=(i<=na?A[i]+0:0); y=(i<=nb?B[i]+0:0);
|
||||
if(x>y){print 1; exit} if(x<y){print 0; exit}}
|
||||
print 1}'
|
||||
}
|
||||
check_harness_smoke() {
|
||||
local hv_file budget guard_in
|
||||
hv_file="$(jq -r '.harness.version_file // ".claude/harness/VERSION"' "$MANIFEST")"
|
||||
budget="$(jq -r '.harness.registry_desc_budget_chars // 10500' "$MANIFEST")"
|
||||
guard_in="$(jq -r '.harness.guard_wired_in // ".claude/scripts/sync.sh"' "$MANIFEST")"
|
||||
local minver; minver="$(jq -r '.harness.min_version // empty' "$MANIFEST")"
|
||||
|
||||
# 1. VERSION marker present + not older than min_version
|
||||
local vpath="$REPO_ROOT/$hv_file" have
|
||||
if [ ! -f "$vpath" ]; then
|
||||
emit harness.version harness FAIL "harness VERSION marker missing: $hv_file" \
|
||||
"Restore via /sync (git pull); this machine may be on a pre-1.3.0 harness"
|
||||
else
|
||||
have="$(tr -d '[:space:]' < "$vpath")"
|
||||
if [ -n "$minver" ] && [ "$(ver_ge "$have" "$minver")" != "1" ]; then
|
||||
emit harness.version harness WARN "harness VERSION $have is older than baseline min $minver" \
|
||||
"Run /sync to pull the current harness; behavior may differ from the fleet"
|
||||
else
|
||||
emit harness.version harness PASS "harness VERSION $have (>= min ${minver:-n/a})"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Skill-registry description budget (sum of all SKILL.md description: fields)
|
||||
local total=0 f n=0 sub
|
||||
for f in "$REPO_ROOT"/.claude/skills/*/SKILL.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sub="$(awk '
|
||||
/^---[ \t]*$/ { d++; next }
|
||||
d==1 {
|
||||
if ($0 ~ /^[A-Za-z0-9_-]+:/ && $0 !~ /^description:/) indesc=0
|
||||
if ($0 ~ /^description:/) indesc=1
|
||||
if (indesc) total += length($0)
|
||||
}
|
||||
d>=2 { print total+0; exit }
|
||||
END { if (d<2) print total+0 }' "$f")"
|
||||
total=$((total + ${sub:-0})); n=$((n+1))
|
||||
done
|
||||
if [ "$total" -gt "$budget" ] 2>/dev/null; then
|
||||
emit harness.registry_budget harness WARN \
|
||||
"skill-registry descriptions = $total chars over budget $budget ($n skills) — registry bloat taxes every session" \
|
||||
"Trim the longest skill description(s) to one line; move triggers/examples into the SKILL.md body"
|
||||
else
|
||||
emit harness.registry_budget harness PASS "skill-registry descriptions $total/$budget chars ($n skills)"
|
||||
fi
|
||||
|
||||
# 3. Global deploy targets populated (Phase 5b/5c deploy; empty = the Mac-wipe failure)
|
||||
local gs gc
|
||||
gs="$(ls -1d "$HOME"/.claude/skills/*/ 2>/dev/null | wc -l | tr -d '[:space:]')"
|
||||
gc="$(ls -1 "$HOME"/.claude/commands/*.md 2>/dev/null | wc -l | tr -d '[:space:]')"
|
||||
if [ "${gs:-0}" -eq 0 ] 2>/dev/null; then
|
||||
emit harness.deploy_skills harness WARN "~/.claude/skills is EMPTY (global skill deploy missing)" \
|
||||
"Run /sync — sync.sh Phase 5c redeploys skills to ~/.claude/skills"
|
||||
else
|
||||
emit harness.deploy_skills harness PASS "~/.claude/skills populated ($gs skills)"
|
||||
fi
|
||||
if [ "${gc:-0}" -eq 0 ] 2>/dev/null; then
|
||||
emit harness.deploy_commands harness WARN "~/.claude/commands has no .md files (global command deploy missing)" \
|
||||
"Run /sync — sync.sh Phase 5b redeploys commands to ~/.claude/commands"
|
||||
else
|
||||
emit harness.deploy_commands harness PASS "~/.claude/commands populated ($gc commands)"
|
||||
fi
|
||||
|
||||
# 4. Guard wired into sync.sh + executable
|
||||
local guard="$REPO_ROOT/.claude/scripts/harness-guard.sh"
|
||||
if [ ! -f "$guard" ]; then
|
||||
emit harness.guard harness WARN "harness-guard.sh missing" "Restore via /sync"
|
||||
elif ! grep -q "harness-guard" "$REPO_ROOT/$guard_in" 2>/dev/null; then
|
||||
emit harness.guard harness WARN "harness-guard.sh not wired into $guard_in (pre-commit guard inactive)" \
|
||||
"Re-check $guard_in: it should call harness-guard.sh before commit"
|
||||
else
|
||||
emit harness.guard harness PASS "harness-guard.sh present + wired into $guard_in"
|
||||
fi
|
||||
|
||||
# 5. Core scripts parse (bash -n) + now-phoenix emits a valid date
|
||||
local s sp
|
||||
for s in $(jq -r '.harness.syntax_check_scripts[]? // empty' "$MANIFEST"); do
|
||||
sp="$REPO_ROOT/$s"
|
||||
if [ ! -f "$sp" ]; then
|
||||
emit "harness.syntax.$(basename "$s")" harness WARN "script missing: $s" "Restore via /sync"
|
||||
elif bash -n "$sp" 2>/dev/null; then
|
||||
emit "harness.syntax.$(basename "$s")" harness PASS "parses clean: $s"
|
||||
else
|
||||
emit "harness.syntax.$(basename "$s")" harness FAIL "SYNTAX ERROR in $s (bash -n failed)" \
|
||||
"Fix the parse error: bash -n \"$s\""
|
||||
fi
|
||||
done
|
||||
local nph="$REPO_ROOT/.claude/scripts/now-phoenix.sh" out
|
||||
if [ -f "$nph" ]; then
|
||||
out="$(bash "$nph" --date 2>/dev/null)"
|
||||
if echo "$out" | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then
|
||||
emit harness.now_phoenix harness PASS "now-phoenix.sh --date OK ($out)"
|
||||
else
|
||||
emit harness.now_phoenix harness WARN "now-phoenix.sh --date returned unexpected output: '$out'" \
|
||||
"Check .claude/scripts/now-phoenix.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 6. Guard self-test: run the full false-positive/true-positive matrix in an isolated
|
||||
# temp repo (writes only under mktemp, never the real tree). Proves the guard still
|
||||
# detects real conflicts/secrets AND does not false-positive on legit content — the
|
||||
# standing prerequisite for promoting the guard to FATAL.
|
||||
local gt="$REPO_ROOT/.claude/scripts/test-harness-guard.sh" gres
|
||||
if [ -f "$gt" ] && command -v git >/dev/null 2>&1; then
|
||||
gres="$(bash "$gt" 2>/dev/null | grep 'RESULT:' | head -1 | sed 's/^[[:space:]]*RESULT:[[:space:]]*//')"
|
||||
if echo "$gres" | grep -q 'FAIL 0'; then
|
||||
emit harness.guard_selftest harness PASS "guard FP/TP matrix clean ($gres)"
|
||||
elif [ -n "$gres" ]; then
|
||||
emit harness.guard_selftest harness WARN "guard self-test reported failures ($gres)" \
|
||||
"Run: bash .claude/scripts/test-harness-guard.sh — a detection case regressed"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CHECK: command <-> standard consistency (the "command-restates-standard" lint).
|
||||
# Deterministic core only: for each manifest-declared (command, standard) link,
|
||||
# verify the standard still contains its defer-to-SSOT pointer (must_reference).
|
||||
# A standard that loses the pointer has likely drifted back into RESTATING the
|
||||
# command's rules -- the exact failure mode behind the Syncro timers contradiction
|
||||
# (standard said 'always timer' while /syncro said 'outlier only'). The SEMANTIC
|
||||
# pass (read both, judge actual contradiction) is delegated to the model in
|
||||
# SKILL.md, mirroring check_memory.
|
||||
# ---------------------------------------------------------------------------
|
||||
check_command_standard() {
|
||||
local has; has="$(jq -r '(.command_standard_links // []) | length' "$MANIFEST" 2>/dev/null)"
|
||||
[ "${has:-0}" -gt 0 ] 2>/dev/null || return
|
||||
local topic stdf ref why p
|
||||
while IFS=$'\t' read -r topic stdf ref why; do
|
||||
[ -n "$topic" ] || continue
|
||||
p="$REPO_ROOT/$stdf"
|
||||
if [ ! -f "$p" ]; then
|
||||
emit "consistency.$topic" consistency WARN "standard missing for '$topic': $stdf" \
|
||||
"Restore via /sync, or remove the link from manifest.command_standard_links"
|
||||
elif grep -qiE "$ref" "$p" 2>/dev/null; then
|
||||
emit "consistency.$topic" consistency PASS "'$topic' standard defers to the owning command (SSOT pointer present)"
|
||||
else
|
||||
emit "consistency.$topic" consistency WARN \
|
||||
"'$topic' standard ($stdf) lost its defer-to-SSOT pointer ($why)" \
|
||||
"Re-add the pointer to the owning command, and confirm the standard does NOT restate or contradict it"
|
||||
fi
|
||||
done < <(jq -r '(.command_standard_links // [])[] | [.topic, .standard, .must_reference, .why] | @tsv' "$MANIFEST")
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build the census JSON from accumulated results
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -730,6 +890,8 @@ check_git
|
||||
check_skills_commands
|
||||
check_duplicates
|
||||
check_memory
|
||||
check_harness_smoke
|
||||
check_command_standard
|
||||
check_vault
|
||||
check_connectivity
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
---
|
||||
name: grepai-first
|
||||
description: Search with GrepAI or Grep before opening any file for context; Read only when full content is needed
|
||||
description: Wiki first for known-entity facts; then search with GrepAI/Grep before opening any file for code/discovery; Read only when full content is needed
|
||||
applies-to: all
|
||||
---
|
||||
|
||||
# Context Lookup — GrepAI First
|
||||
# Context Lookup — search before reading (wiki first for known entities)
|
||||
|
||||
Before reading any file for context, search with GrepAI or Grep. Only open a file when you need its full content for editing or line-by-line review.
|
||||
Two-part rule:
|
||||
|
||||
1. **Known-entity facts** (a specific client/project/system — its IPs, creds paths, architecture):
|
||||
check the **wiki** (`wiki/`) FIRST. It is the synthesized truth layer and is already distilled —
|
||||
cheaper than re-deriving from raw logs/code.
|
||||
2. **Everything else** (code, discovery, un-compiled detail): search with **GrepAI or Grep before
|
||||
opening any file**. Only open a file when you need its full content for editing or line-by-line
|
||||
review.
|
||||
|
||||
GrepAI's irreplaceable value is **code** (call-graph tracing over the Rust+TS corpus the wiki can't
|
||||
see). Do NOT GrepAI something the wiki already answers — that's redundant overlap.
|
||||
|
||||
## Lookup table
|
||||
|
||||
@@ -18,8 +28,9 @@ Before reading any file for context, search with GrepAI or Grep. Only open a fil
|
||||
| Find what a function calls | `grepai_trace_callees` |
|
||||
| Full file content needed (edit, review) | `Read` |
|
||||
| Recent changes to a file | `git log`, then `Read` specific file |
|
||||
| "What did we do with X?" | `grepai_search` over session logs |
|
||||
| "How is Y configured?" | `grepai_search` before checking any specific file |
|
||||
| "What did we do with client/system X?" | **wiki article first**, then `grepai_search` over session logs for detail below the wiki's summary |
|
||||
| "How is Y configured?" (known entity) | **wiki first**, then `grepai_search` / the specific file |
|
||||
| "How is Y configured?" (code/unknown) | `grepai_search` before opening any file |
|
||||
|
||||
## Token cost rationale
|
||||
|
||||
|
||||
@@ -1,62 +1,44 @@
|
||||
---
|
||||
name: time-entry-protocol
|
||||
description: Always use timer_entry flow for billing; ask minutes and labor type before logging any time; never assume defaults
|
||||
description: Normal Syncro billing uses add_line_item per the /syncro command; timers are an outlier path used only when Mike explicitly requests one; always confirm minutes + labor type before logging.
|
||||
applies-to: syncro
|
||||
---
|
||||
|
||||
# Syncro Time Entry Protocol
|
||||
|
||||
## Always ask before logging time
|
||||
## Source of truth
|
||||
|
||||
Before logging any time entry, ask the user:
|
||||
1. How many minutes?
|
||||
2. What labor type? (onsite, remote, emergency, warranty, project, etc.)
|
||||
The `/syncro` command (`.claude/commands/syncro.md`) is the SINGLE source of truth for
|
||||
the billing mechanics — product IDs, rates, emergency and prepaid handling, the
|
||||
line-item + invoice flow. Do not duplicate or contradict it here. This standard states
|
||||
only the cross-cutting discipline.
|
||||
|
||||
Never assume a default. Never round up or fill in a number. Billing errors are client-facing, hard to reverse, and affect prepaid block balances. An incorrect time entry requires Winter (billing) to manually reverse it.
|
||||
## Normal billing = add_line_item
|
||||
|
||||
## The required flow
|
||||
Routine labor bills directly via `POST /tickets/{id}/add_line_item` (see the /syncro
|
||||
command for the exact payload, product IDs, and `price_retail` rules). This is the
|
||||
standard, expected path for all normal billing. (Confirmed by Mike, 2026-06-08.)
|
||||
|
||||
All time-bearing work must use `timer_entry → charge_timer_entry`, not bare `add_line_item`. This is a hard rule.
|
||||
## Timers are an OUTLIER — not the billing loop
|
||||
|
||||
```
|
||||
1. POST /tickets/{id}/timer_entry — create the time record
|
||||
2. POST /tickets/{id}/charge_timer_entry — generate the line item from the timer
|
||||
3. Verify line item: GET /tickets/{id} → check price_retail on the new line item
|
||||
4. If price_retail is wrong: PUT /tickets/{id}/line_items/{item_id} to patch it
|
||||
5. POST /invoices — roll line item onto invoice
|
||||
6. PUT /tickets/{id} — set status to Invoiced
|
||||
```
|
||||
`timer_entry → charge_timer_entry` is NOT part of normal billing. Use it ONLY when Mike
|
||||
explicitly asks for a timer on a specific job. The capability stays available, but it is
|
||||
never the default and routine labor is never routed through it.
|
||||
|
||||
The `add_line_item` endpoint bypasses Syncro's time-tracking table entirely. Using it for labor means hours appear in the invoice but not in time-tracking reports (hours per client, technician productivity, average resolution time, prepay burn rate). After the 2026-04-30 audit, 31 closed tickets had 00:00:00 in time tracking because bare `add_line_item` was used for all of them.
|
||||
When a timer IS explicitly requested:
|
||||
1. `POST /tickets/{id}/timer_entry` → 2. `POST /tickets/{id}/charge_timer_entry` →
|
||||
verify the generated line item's `price_retail` (patch via `update_line_item` if wrong).
|
||||
- `billable: false` is silently ignored by the API on `timer_entry` — for warranty/free,
|
||||
verify in the GUI that the charged line landed at $0 and patch if not.
|
||||
|
||||
## When bare add_line_item is acceptable
|
||||
## Always confirm before logging (either path)
|
||||
|
||||
Only for non-time items:
|
||||
- Hardware/parts
|
||||
- Flat-fee services with no labor component
|
||||
- Software licenses
|
||||
Before logging any time, confirm: (1) how many minutes, (2) what labor type — onsite /
|
||||
remote / emergency / warranty / project. Never assume a default or round up. Billing
|
||||
errors are client-facing, hard to reverse, and affect prepaid block balances (Winter has
|
||||
to reverse them manually).
|
||||
|
||||
Even warranty or free labor must use `timer_entry` with `billable: false`. The only exception is cancelled tickets where no work was performed.
|
||||
## Prepaid
|
||||
|
||||
## Labor type reference
|
||||
|
||||
| Situation | Product | Note |
|
||||
|-----------|---------|-------|
|
||||
| Standard onsite | `26118` Onsite Business | At `hours × $175` |
|
||||
| Emergency/after-hours | `26184` Emergency or After Hours | Full rate, no quantity multiplier |
|
||||
| Prepaid project labor | `9269129` Prepaid Project Labor | At `$0/hr`; debits from prepay block |
|
||||
| Warranty | Any labor product | `billable: false` on timer_entry |
|
||||
|
||||
## Prepaid customers
|
||||
|
||||
Before applying any rate, verify `prepay_hours` on the customer record:
|
||||
```bash
|
||||
curl -s "https://computerguru.syncromsp.com/api/v1/customers/${CUSTOMER_ID}?api_key=${API_KEY}" \
|
||||
| jq '.customer.prepay_hours'
|
||||
```
|
||||
|
||||
If `prepay_hours > 0`, use the prepaid product at `$0/hr` and verify the balance debits correctly after the invoice posts (Syncro may not debit until the invoice is paid in the GUI — flag for Winter if uncertain).
|
||||
|
||||
## Note on billable: false
|
||||
|
||||
The Syncro API ignores `billable: false` on `timer_entry` calls silently — the entry is created but the billing flag has no effect through the API. If a warranty/free entry is needed, create the timer entry, then verify through the GUI that the line item generated by `charge_timer_entry` is at $0. Patch with `update_line_item` if it came in at a non-zero rate.
|
||||
Check `prepay_hours` on the customer before billing — the /syncro command holds the
|
||||
authoritative prepaid + emergency rules.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"git_name": "Mike Swanson",
|
||||
"git_email": "mike@azcomputerguru.com",
|
||||
"discord_id": "264814939619721216",
|
||||
"notes": "Owner. Full access to everything. Primary machine: GURU-5070 (as of 2026-05-25). Previous machine DESKTOP-0O8A1RL retired."
|
||||
},
|
||||
"howard": {
|
||||
@@ -26,6 +27,7 @@
|
||||
],
|
||||
"git_name": "Howard Enos",
|
||||
"git_email": "howard@azcomputerguru.com",
|
||||
"discord_id": "624667664501178379",
|
||||
"gitea_username": "howard",
|
||||
"notes": "Employee, Mike's brother. Full trust. Same access as Mike for MSP tracking and daily work. Has own Gitea account (howard) with admin access to all repos. Password rotated 2026-04-21 — stored in Howard's 1Password, not in this file."
|
||||
},
|
||||
@@ -38,6 +40,18 @@
|
||||
"discord_id": "261978810713505792",
|
||||
"known_machines": [],
|
||||
"notes": "Web developer contractor. No direct ClaudeTools CLI access. Interacts only through the Discord bot. Authorized scope: M365/365 remediations (remediation-tool skill), IX hosting changes (DNS, cPanel accounts, file management on IX/Websvr), Syncro read. Cannot modify bot behavior, skills, CLAUDE.md, DISCORD_CLAUDE.md, users.json, vault entries, or git history."
|
||||
},
|
||||
"winter": {
|
||||
"full_name": "Winter Williams",
|
||||
"email": "wwilliams@azcomputerguru.com",
|
||||
"role": "tech",
|
||||
"title": "Syncro SME (Discord bot only)",
|
||||
"syncro_user_id": 1737,
|
||||
"discord_id": "624666486362996755",
|
||||
"git_name": "Winter Williams",
|
||||
"git_email": "wwilliams@azcomputerguru.com",
|
||||
"known_machines": [],
|
||||
"notes": "Full trust. Go-to SME for Syncro / ticketing — defer Syncro decisions to her. Interacts ONLY through the Discord bot; no installed Claude CLI sessions."
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
|
||||
6
.claude/wiki_staging/README.md
Normal file
6
.claude/wiki_staging/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# wiki_staging
|
||||
|
||||
Transient staging for `/wiki-compile` (Task 2 of the harness-optimization spec). The
|
||||
synthesized article is written here FIRST, the diff vs the live `wiki/` article is
|
||||
reviewed, and only then applied to the live tree and committed. Staged `*.md` files are
|
||||
gitignored and removed after apply — nothing here is canonical.
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -103,3 +103,10 @@ clients/internal-infrastructure/datto-bsod-case-2026-05-16.zip
|
||||
clients/internal-infrastructure/datto-bsod-case-2026-05-16/
|
||||
|
||||
temp/
|
||||
|
||||
# Microsoft Office temp/lock files
|
||||
~$*
|
||||
|
||||
# Wiki synthesis staging (transient; review-before-apply). Keep only the README.
|
||||
.claude/wiki_staging/*
|
||||
!.claude/wiki_staging/README.md
|
||||
|
||||
12
AGENTS.md
Normal file
12
AGENTS.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Independent reviewer context
|
||||
|
||||
You are invoked by ClaudeTools (an MSP automation repo) as an INDEPENDENT
|
||||
second-opinion model — for verify, review, and one-shot answers. You are NOT
|
||||
the owner of this codebase: do not propose to edit, commit, or run destructive
|
||||
commands. Claude owns the code; your value is fresh, skeptical eyes.
|
||||
|
||||
Output rules:
|
||||
- No emojis. Use ASCII markers: [OK] [WARN] [ERROR] [INFO].
|
||||
- Be concise and concrete: lead with the verdict, then the reasoning.
|
||||
- When verifying a claim, actively try to REFUTE it; state your confidence.
|
||||
- Cite file:line when reviewing code.
|
||||
12
GEMINI.md
Normal file
12
GEMINI.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Independent reviewer context
|
||||
|
||||
You are invoked by ClaudeTools (an MSP automation repo) as an INDEPENDENT
|
||||
second-opinion model — for verify, review, and one-shot answers. You are NOT
|
||||
the owner of this codebase: do not propose to edit, commit, or run destructive
|
||||
commands. Claude owns the code; your value is fresh, skeptical eyes.
|
||||
|
||||
Output rules:
|
||||
- No emojis. Use ASCII markers: [OK] [WARN] [ERROR] [INFO].
|
||||
- Be concise and concrete: lead with the verdict, then the reasoning.
|
||||
- When verifying a claim, actively try to REFUTE it; state your confidence.
|
||||
- Cite file:line when reviewing code.
|
||||
@@ -1,68 +0,0 @@
|
||||
# Stage TXT Import Task
|
||||
# Date: 2026-03-28
|
||||
# Context: CTONWTXT.BAT now uploads C:\STAGE\*.TXT from DOS machines to T:\STAGE\%MACHINE%\
|
||||
|
||||
## What happened
|
||||
|
||||
1. CTONWTXT.BAT was never being called -- fixed, now called from CTONW.BAT on every boot
|
||||
2. Destination changed from broken X: (Novell serve.sys check) to T:\STAGE\%MACHINE%\
|
||||
3. DOS 6.22 can't MD on existing dirs without error, so dirs are pre-created on NAS
|
||||
4. All TS-* machine folders pre-created under /data/test/STAGE/ on D2TESTNAS
|
||||
|
||||
## What needs to run
|
||||
|
||||
Save the script below as C:\Shares\testdatadb\import-all-stage.js and run it:
|
||||
|
||||
cd C:\Shares\testdatadb
|
||||
node import-all-stage.js
|
||||
|
||||
## What it does
|
||||
|
||||
- Scans \\D2TESTNAS\test\STAGE\TS-*\*.TXT (~8,100 files across 10 machines)
|
||||
- Parses each TXT datasheet (Date, Model, SN)
|
||||
- Decodes hex-prefix serial numbers for 8.3 filename encoding:
|
||||
- Letter prefix = hex digit: A=10, B=11, C=12, ..., H=17, etc.
|
||||
- Example: H8236-12.TXT has SN: 178236-12 inside the file
|
||||
- Example: A819-1.TXT has SN: A819-1 inside -> decoded to 10819-1
|
||||
- The SN line inside H-prefix files already has the full numeric serial
|
||||
- The SN line inside A-prefix files still has the encoded serial
|
||||
- Cross-references against testdata.db by (serial_number, model_number)
|
||||
- Inserts MISSING records as log_type='SHT' with test_station from folder name
|
||||
- Copies ALL files to X:\For_Web\{decoded_serial}.TXT (the web share)
|
||||
|
||||
## Machines with data
|
||||
|
||||
TS-4L: 3,082 files (largest)
|
||||
TS-4R: 2,741 files
|
||||
TS-1R: 509 files
|
||||
TS-8R: 478 files
|
||||
TS-3R: 435 files
|
||||
TS-11R: 325 files
|
||||
TS-8L: 285 files
|
||||
TS-11L: 248 files
|
||||
TS-27: 10 files (already imported this session)
|
||||
TS-1L: 1 file
|
||||
|
||||
## Serial number encoding (8.3 filename scheme)
|
||||
|
||||
The QuickBASIC ATE software encodes long serial numbers to fit DOS 8.3 filenames.
|
||||
The first two digits get replaced with a hex letter if the serial is too long:
|
||||
|
||||
178236-12 -> H8236-12.TXT (17 -> H, which is char code 72, 72-55=17)
|
||||
10819-1 -> A819-1.TXT (10 -> A, which is char code 65, 65-55=10)
|
||||
|
||||
Decode: letter.charCodeAt(0) - 55 = numeric prefix
|
||||
Only applies if filename starts with [A-Z] followed by digits.
|
||||
|
||||
## TS-27 already done
|
||||
|
||||
10 files from TS-27 were already imported earlier this session into the DB as SHT records.
|
||||
The import script uses INSERT OR REPLACE so re-running is safe.
|
||||
|
||||
## Previous CTONWTXT.BAT issues (resolved)
|
||||
|
||||
- v1.0: Never called, checked for Novell serve.sys, used X: drive parameter
|
||||
- v2.0: Called from CTONW, but used mixed-case "Stage" path -> failed on DOS
|
||||
- v2.1: All uppercase STAGE, but had MD commands that fail on existing dirs
|
||||
- v2.2: Same issue
|
||||
- v2.3: Removed MD entirely, dirs pre-created on NAS. CURRENT VERSION.
|
||||
@@ -1,80 +0,0 @@
|
||||
Subject: Test Datasheets - Weekend Update: All 73 Quatronix Sheets Generated, Work Order Search Live
|
||||
|
||||
John, Ken,
|
||||
|
||||
Quick update on progress since Friday's email. The pipeline is significantly further along.
|
||||
|
||||
## Quatronix Customer Issue - RESOLVED
|
||||
|
||||
All 73 requested datasheets have been generated (TXT + PDF). The last holdout was SCM5B49-05 (SN 177000-15) — the 5B49DATA.DAT spec file was empty, but John pointed us to 5B49_2.DAT which had the data. All 73 files are ready to send to Peter/Ginger.
|
||||
|
||||
## Model Spec Coverage Expanded
|
||||
|
||||
We went from 751 model specs to 1,470+ by loading additional spec databases:
|
||||
|
||||
| Spec File | Family | Models |
|
||||
|-----------|--------|--------|
|
||||
| 5BMAIN.DAT | SCM5B | 481 |
|
||||
| 5B45DATA.DAT | SCM5B (freq/counter) | 56 |
|
||||
| DB5B48.DAT | SCM5B (multi-bandwidth) | 3 |
|
||||
| 5B49_2.DAT | SCM5B (sample & hold) | 15 |
|
||||
| 8BMAIN.DAT | 8B | 148 |
|
||||
| DSCOUT.DAT | DSCA (output) | 23 |
|
||||
| DSCMAIN4.DAT | DSCA (input) | 391 |
|
||||
| SCTMAIN.DAT | DSCT | 103 |
|
||||
| 7BMAIN.DAT | SCM7B | 276 |
|
||||
|
||||
If there are additional spec files we're missing, let me know the paths and we'll add them.
|
||||
|
||||
## SCM7B Support Added
|
||||
|
||||
The 7B product family is now fully supported in the datasheet generator:
|
||||
- 31 test parameters (vs 20 for SCM5B)
|
||||
- Correct header ("SCM" prefix prepended to model name)
|
||||
- 120VAC Withstand / Hi-Pot (skipped for 7BPT models)
|
||||
- "Packing Check List" with blank fields (vs pre-marked checkboxes on 5B/8B)
|
||||
- "Tested by" and "QC" signature lines
|
||||
- Note: The 7B DAT format (single CSV line) doesn't include individual accuracy test points, so the accuracy table is omitted. Only the Final Test Results section is generated from DAT data.
|
||||
|
||||
## Work Order Search & Linking
|
||||
|
||||
Imported all 33,745 work order status reports from the test station Reports folders:
|
||||
- 63,263 individual test lines parsed (serial number, model, pass/fail, date/time, station)
|
||||
- 2.27 million test records linked to their work orders
|
||||
|
||||
In the web app (http://192.168.0.6:3000):
|
||||
- New "Work Order #" search field — enter a WO number to find all associated test records
|
||||
- Click the WO number in any record's detail view to see the full work order:
|
||||
- All serial numbers tested under that WO
|
||||
- Pass/fail status for each (including retests)
|
||||
- Test program and version used
|
||||
- Test station and timestamps
|
||||
- New work order reports are automatically imported when synced from the NAS
|
||||
|
||||
## Datasheet Formatting Refined
|
||||
|
||||
Compared generated datasheets against originals from the DFWDS archive and fixed column alignment to match the QuickBASIC output:
|
||||
- TAB positions match exactly (parameter names, measured values, spec limits, pass/fail)
|
||||
- Number formatting matches QB PRINT USING (right-justified, correct decimal places)
|
||||
- STR$() behavior replicated (leading space for positive numbers, dropped leading zeros)
|
||||
- Spec limit formatting matches (e.g., "+/- .03 %" not "+/- 0.03 %")
|
||||
|
||||
## View Button Updated
|
||||
|
||||
The "SHEET" button in the web app now shows a styled HTML page that matches the PDF/TXT layout — white page, monospace font, same column alignment. Includes Print and Download PDF buttons.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- Created domain service account (INTRANET\svc_testdatadb) for the TestDataDB Windows service — resolves the file permission issues we were hitting
|
||||
- Added STAGE folder sync to the NAS sync script — TXT datasheets from DOS machines will now be pulled to AD2 automatically
|
||||
- Work order report import added to sync script — new reports are ingested automatically every 15 minutes
|
||||
|
||||
## Still Open
|
||||
|
||||
1. **Website upload** — The old Uploader.aspx endpoints are dead. Need to determine the new upload mechanism for dataforth.com.
|
||||
2. **STAGE backlog** — ~8,100 TXT files on the NAS from DOS machines need to be processed (script ready, haven't run it yet).
|
||||
3. **Pending ForWeb export** — ~845K records in the database don't have TXT files in For_Web yet (mostly 7B and older records). Can batch-export as needed.
|
||||
|
||||
Let me know if you need anything else.
|
||||
|
||||
Mike
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user