diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7e5733 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store + diff --git a/handoffs/2026-08-08-codex-phase4-handoff.md b/handoffs/2026-08-08-codex-phase4-handoff.md new file mode 100644 index 0000000..6d66310 --- /dev/null +++ b/handoffs/2026-08-08-codex-phase4-handoff.md @@ -0,0 +1,96 @@ +--- +date: 2026-08-08 +revised: 2026-08-09 (direction lock + verification follow-up; Mission 1 COMPLETE same day) +type: handoff +project: the-guild +audience: codex (local session on Brandon's Mac) +task: COMPLETE — no active Codex launch task; skills and probe calibration DEFERRED +from: claude.ai/code remote session (build-phase session) +supersedes: handoffs/2026-08-08-codex-repo-seed-handoff.md (seed mission COMPLETE) +--- + +# HANDOFF → CODEX #2 (revised) — Mac-side lane — 2026-08-09 + +You are the local agent with Brandon's credentials and his Mac. Seed +mission complete. This revision reflects Brandon's locked direction of +2026-08-09: exactly ONE active mission (the Lehi trades list). The API +calibration and the skill installs are both DEFERRED — read those +sections before doing anything beyond Mission 1. + +## Direction lock (Brandon's authority — do not reopen) + +- The first-door demo is **ordinary subscription ChatGPT on the + prospect's phone**. The paid Responses-API probe is a separate INTERNAL + baseline/proof lane, not a pre-door blocker. +- **Do not request an API key. Do not make paid API calls.** The probe + lane activates at the first close, or when Brandon explicitly says so. +- **Do not create or install skills.** Skill work is deferred reference, + not a launch gate. +- First learn-sprint territory: **Lehi, Utah**. +- Approved playbook and price tiers stand; invent no new scripts, tiers, + legal architecture, or automation. + +## Probe state of record (confirmed by Brandon, 2026-08-09) + +- Repo: `brandonwadepackard-cell/guild-visibility-probe` (private), + `main` at `0e455bfd200da0d3f64892c6f6915de9cb77d882`. +- Default model: `gpt-5.6-terra`. Tests: **8/8 passing**; `import app, + cli` clean; secret scan clean. +- `probe/questions.yaml` carries the Lehi wave-one trades (sprinkler, + garage_door, auto_repair added alongside the original ten). +- The search-required and invalid-run protections are load-bearing + doctrine. **Do not revert or weaken them** — not for convenience, not + to make a future run pass, not in any refactor. + +## MISSION 1 — Lehi trades list — ✅ COMPLETE (2026-08-09) + +Receipt: `probe/questions.yaml` updated with exactly sprinkler, +garage_door, auto_repair (wave-one set now complete alongside the +original ten trades); pushed to `main` @ +`0e455bfd200da0d3f64892c6f6915de9cb77d882`; pytest 8/8; `import app, +cli` clean; secret scan clean; repo confirmed private. **No remaining +Codex launch task.** Both remaining lanes below stay deferred until +Brandon explicitly triggers them. + +## DEFERRED — skills (reference only; do NOT create or install) + +The three mined skills (`appearance-rate-probe`, +`four-elements-sales-scripts`, `cold-outreach-legality-gate`) are +deferred reference material, not launch work. Their full text lives in +the workspace repo at `journal/2026-08-07-skill-mining-register.md` +(reachable through Brandon). If Brandon explicitly triggers skill work +later, the standing rules for it are: this Mac's `~/.claude/skills` and +`~/.codex/skills` both point to the canonical store +**`~/Projects/skills`** — install once into the canonical store only, +reconcile (never blindly overwrite) if a same-named skill exists, and +run the store's canonical backup workflow. Until that trigger: nothing. + +## DEFERRED — probe calibration + customer baselines (do NOT start) + +Previously "Phase 4." It activates ONLY when Brandon says "first close" +or explicitly triggers the probe lane. Until then: no `.env` key, no +paid calls, no calibration runs. When activated, the protocol is: +smoke run one business → five known businesses hand-verified with +Brandon (≥4/5 same direction) → franchise-high / new-biz-zero +calibration pair → web UI click-through → then the first real customer +baseline within the week promised at the close. Integrity rules +(search-on, no partial runs, append-only, locked baselines) apply +unchanged; fix API drift forward in normal commits, never by weakening +a rule. Stochastic scores are the product, not a bug. + +## Rules (all missions) + +- Never commit `.env`, keys, or passcodes. +- Only touch `brandonwadepackard-cell/guild-visibility-probe`. + Nothing under `abcnuts` (unreachable anyway); no skills directories + unless Brandon triggers skill work. +- No force pushes. No new repos. No probe v2 features. + +## Report back to Brandon (he relays to the cloud Claude session) + +``` +Mac lane results (reported 2026-08-09): +- Trades list: COMPLETE — main @ 0e455bfd200da0d3f64892c6f6915de9cb77d882 +- Probe lane: NOT activated (per direction lock) +- Skills: deferred, none created (per direction lock) +``` diff --git a/handoffs/2026-08-09-guild-handoff.md b/handoffs/2026-08-09-guild-handoff.md new file mode 100644 index 0000000..357780b --- /dev/null +++ b/handoffs/2026-08-09-guild-handoff.md @@ -0,0 +1,115 @@ +--- +date: 2026-08-09 +type: handoff +project: the-guild +status: active +from: claude.ai/code remote session (build-phase + direction-lock session) +supersedes: handoffs/2026-08-07-guild-handoff.md +--- + +# HANDOFF — The Guild — 2026-08-09 (canonical) + +Read this cold and you can continue. Everything below is verified state +or Brandon's explicit decision — receipts cited per line. + +## 1. Mission state in one paragraph + +The Guild sells local businesses measure → improve → prove: a measured +AI appearance rate, the fix work, and a before/after report — guaranteeing +only the work and the report, never placement or score. The business shape +is wedge → ladder → referral flywheel; the small offer is customer +acquisition. Strategy is CLOSED, the toolchain is BUILT, and the next +action is the first 20-door learn sprint in **Lehi, Utah** — run +zero-cost: the door demo is ordinary subscription ChatGPT on the +prospect's own phone, and the offline field log is the canonical record. +No Guild GHL account exists (verified 8/9) — funnel, dedicated number, +and printed cards are post-signal procurement AFTER a close, not launch +prerequisites. The paid API probe is an internal proof lane that +activates at the first close — until then no API key, no paid calls. + +## 2. State of assets (receipts) + +| Asset | Where | State | +|---|---|---| +| Probe MVP | `brandonwadepackard-cell/guild-visibility-probe` (private) `main` @ `0e455bfd200da0d3f64892c6f6915de9cb77d882` | live; default `gpt-5.6-terra`; 8/8 tests; import + secret scan clean; Lehi wave-one trades in `questions.yaml` (added sprinkler, garage_door, auto_repair) — Brandon-confirmed 2026-08-09; search-required + invalid-run protections intact | +| Journals, handoffs, playbook, plans | `abcnuts/claude-code-workspace` `main` @ `e0d801b` + PR #6 (draft, unmerged) | durable custody | +| Fulfillment playbook v1 + price tiers | `playbooks/fulfillment-playbook-v1.md` | **approved** by Brandon | +| Build plan (revised) | `plans/2026-08-08-build-phase-plan.md` | active | +| Lehi sprint plan | `plans/2026-08-09-lehi-learn-sprint.md` | ready now — zero-cost, needs only a phone and doors | +| **Guild Command Center (canonical first-sprint surface)** | `playbooks/guild-command-center.html` + committed tests + private artifact d2fe8d92-9c56-4a56-bb3a-20f91a8f4cb7 + **LIVE: https://guild-command-center.brandonwadepackard.workers.dev** | v1.0.1 verified **164/164** (137 command-center + 27 PWA) at source commit `a46c2ba`; saved doors open as read-only full-detail disclosures; deployed as installable offline PWA on a static Cloudflare Worker (no bindings/storage/analytics/API; public-by-URL; data device-local); live checks passed (detail open, close/reopen, offline reopen, zero external requests, CSP/DENY/no-cache, `X-Guild-Commit: a46c2ba…`); actual-iPhone install/save/open/export/airplane-reopen/reset proof complete; full receipt `pwa/RELEASE-2026-08-09.md`; verdict **RELEASE_READY** for the Lehi field sprint | +| Offline field log (EMERGENCY fallback only) | `playbooks/lehi-field-log.html` + `lehi-field-log-template.csv` + private artifact e52d93c2-62b6-464c-9c9b-8c6eb0208edb | pre-schema-revision (lacks owner/sit-down fields); entries logged there read as incomplete in the results gate | +| Funnel build sheet + consent language | `playbooks/ghl-funnel-build-sheet.md` | spec only — procurement deferred until after a close | +| Price card (honesty-fixed) | `playbooks/price-card-print.html` + artifact 9302a24e-24a1-48b2-8e29-aa83476edcea | post-signal — print after a number exists | +| One-pager v2 / Script Book v1 | artifacts d51a71b4… / c6c4dfb3… | live (from 8/7) | +| Codex Mac work order | `handoffs/2026-08-08-codex-phase4-handoff.md` (revised) | **COMPLETE** — Lehi trades list done @ `0e455bfd`; no remaining Codex launch task; skills (reference only) + probe lane deferred | + +## 3. Locked decisions (authority: Brandon, 8/7 + 8/9 — do not reopen) + +- Wedge → ladder → referral flywheel; small offer = acquisition. +- Door demo = subscription ChatGPT, prospect's phone, live. Probe = internal + proof lane, activates at first close. No key/no paid calls until then. +- Prices $497 / $997 / $149-mo, fee credits toward month one. Approved. +- Territory: Lehi, Utah. +- Promise language: measure → improve → prove; the no-guarantee line is + doctrine. Customer-facing text never says or implies "we make AI say + your name," guaranteed placement, or guaranteed score. +- Channels: humans open cold doors; AI touches only consented contacts; + never AI voice/SMS/RVM on cold lists. Voice = rent (GHL $97 inbound). +- FLEXX separate. No new scripts, tiers, legal architecture, or + automation beyond what's approved. +- Skill creation/install: deferred — not a launch item; the mined skills + are reference only (`journal/2026-08-07-skill-mining-register.md`). + Any future skill work Brandon triggers uses the canonical + `~/Projects/skills` store (both skill dirs point there), dedupes once, + and runs the canonical backup workflow. + +## 4. Next actions (all Brandon's world; docs and tools are ready) + +1. **Joey dry run** — lanes 1–3 + 5 on a friendly business, timed. The + installed Command Center is the single front door: plan (COMMAND), door + script (WORKFLOW), record (LOG), prospect presentation (CLIENT), and live + gates (RESULTS). +2. **Lehi sprint** — 20 doors per `plans/2026-08-09-lehi-learn-sprint.md`, + zero-cost. Output: close-rate by cohort + complete field-log CSV. +3. Crew decisions before first dollar: rev split, referral reward. + +Post-signal (AFTER a close, procurement decisions — not launch items): +funnel per the build sheet · dedicated line + QR · card printing · +probe-lane activation. + +## 5. Unavoidable blockers (only these) + +Physical doors in Lehi (the sprint) · Brandon's crew (splits). That is +the whole list — nothing is blocked on logins, printing, repos, cloud +sessions, Codex, or any purchase. + +## 5b. GHL reality (verified by Brandon, 2026-08-09) + +No personal/Guild GoHighLevel account exists — Mac browser and connected +mail show no account, signup, or billing evidence. The only live +HighLevel access on the Mac is a location-scoped integration into a +client's sub-account: a separate client system, explicitly OFF-LIMITS +for Guild use. The earlier "GHL login/build" prerequisite was a false +assumption, removed. No account creation, trial, number purchase, or +paid service in this phase. + +## 6. Session-type capabilities (learned the hard way, still true) + +- Cloud Claude authenticates GitHub as `abcnuts`; cannot create repos + (403 by platform design), cannot reach `brandonwadepackard-cell` repos; + mid-session repo attach, Drive writes, send_later are approval-walled. +- Codex on the Mac holds Brandon's credentials: it owns the probe repo + and `~/Projects/skills`. +- Handoffs between the two travel through Brandon (chat file drops + + this repo). + +## 7. Re-entry points (say one of these to a Claude session) + +1. **"Sprint results: …"** (+ field-log CSV) → mine objections, pricing + verdict, script edits by panel ID, wave-two cohorts. +2. **"First close"** → activates BOTH deferred lanes: the probe (Codex + calibration + customer baseline within the week) and the funnel + procurement decision (build sheet is the ready spec). + +Recommended next move: the doors themselves — field log on the phone, +ChatGPT demo ready, twenty knocks in Lehi. Nothing else is in the way. diff --git a/journal/2026-08-09-direction-lock.md b/journal/2026-08-09-direction-lock.md new file mode 100644 index 0000000..c2ca36e --- /dev/null +++ b/journal/2026-08-09-direction-lock.md @@ -0,0 +1,224 @@ +--- +date: 2026-08-09 +type: log +project: the-guild +status: source +tags: +- type/log +- project/the-guild +- direction-lock +--- + +# 2026-08-09 — Direction Lock — Brandon's Confirmed Decisions + +Brandon confirmed the governing direction. These carry decision authority; +do not reopen. + +## Locked + +1. **Business shape:** wedge → ladder → referral flywheel. The small offer + is customer acquisition, not the whole business. +2. **Door demo = ordinary subscription ChatGPT on the prospect's phone,** + live in front of them. The paid Responses-API probe is a separate + INTERNAL baseline/before-after proof lane. It is NOT a pre-door + blocker. No API key requests and no paid API calls in this phase — + the probe lane activates at first close or on Brandon's explicit + trigger. +3. Fulfillment playbook v1 and price tiers ($497/$997/$149-mo): approved. +4. First learn-sprint territory: **Lehi, Utah.** + +## Corrections applied this session (receipts) + +- `plans/2026-08-08-build-phase-plan.md` revised: Phase 4 API calibration + removed as launch gate → deferred lane; territory recorded; gating + items now GHL build → cards → dry run → Lehi sprint. +- `plans/2026-08-09-lehi-learn-sprint.md` created: first-wave scope, + trade cohorts, ChatGPT-on-their-phone demo procedure with honest + handling of both outcomes, GHL recording spec (adds demo_result + field), pass/fail evidence after 20 doors. +- `handoffs/2026-08-08-codex-phase4-handoff.md` revised: Lehi trades + list is the only active mission; Phase 4 explicitly DEFERRED with + no-key/no-paid-calls rule. +- `playbooks/price-card-print.html`: "We work to make it yours" → + "We measure where you stand, do the work, and prove what changed" + (honesty rule: measure → improve → prove; guarantee only the work and + the report). Artifact republished at the same URL. Repo-wide scan + found no other customer-facing violations. + +## Verification follow-up corrections (same day, Brandon's review) + +1. Lehi sprint plan door script: "That's who's getting the call instead + of you" → "That's who ChatGPT named in this answer instead of you" + (one answer proves naming, not call-flow). +2. Same file: "the monthly plan is how you stay there…" → "the monthly + plan is how we keep doing the work and remeasure as it changes" + (no implied visibility guarantee). +3. Skill creation/install removed from the Codex work order and canonical + handoff entirely — the mined skills are deferred reference only + (`journal/2026-08-07-skill-mining-register.md`), not a launch gate. + No skills are to be created. The canonical-store rule + (`~/Projects/skills`, dedupe once, canonical backup) stands for any + future skill work Brandon explicitly triggers. + +## Probe state of record (per Brandon, 2026-08-09 — updated same day) + +`brandonwadepackard-cell/guild-visibility-probe` (private), `main` at +`0e455bfd200da0d3f64892c6f6915de9cb77d882`, default model +`gpt-5.6-terra`, tests 8/8 passing. Search-required and invalid-run +protections intact and to remain so. + +## Codex-local lane COMPLETE (2026-08-09, Brandon's receipt) + +`probe/questions.yaml` updated with exactly sprinkler, garage_door, +auto_repair (Lehi wave-one set complete); pushed to `main` @ +`0e455bfd200da0d3f64892c6f6915de9cb77d882`; pytest 8/8; `import app, +cli` clean; secret scan clean; repo confirmed private. No remaining +Codex launch task. Skills and the paid API lane remain deferred, +unchanged. + +## Lean correction (Brandon-verified authority, 2026-08-09) + +- **No personal/Guild GoHighLevel account exists** — Mac browser and + connected mail show no account/signup/billing evidence. The only live + HighLevel access on the Mac is a location-scoped integration into a + client's sub-account: a separate client system, explicitly OFF-LIMITS + for Guild use. The "GHL login/build" launch prerequisite was a false + assumption and is removed from the first-sprint path. +- No GHL account creation, trial, number purchase, or paid service in + this phase. GHL (or equivalent) is a post-validation procurement + decision AFTER a close; `playbooks/ghl-funnel-build-sheet.md` remains + the ready spec for that day. +- Cards and a dedicated line/QR are post-signal infrastructure, not + prerequisites for the first 20 doors. Prices and doctrine unchanged. +- **Zero-cost first-sprint path built:** `playbooks/lehi-field-log.html` + — self-contained offline field log (no backend, login, libraries, or + network calls; localStorage autosave; progress /20; CSV export with + proper escaping; typed-RESET-gated erase; business info only, no + customer PII) + `playbooks/lehi-field-log-template.csv` fallback. + Private artifact: e52d93c2-62b6-464c-9c9b-8c6eb0208edb. +- **Protocol-integrity revision (same day, independent verification):** + door number enforced 1–20 with duplicate rejection and a completion + lock after 20 unique doors (progress counts unique doors); price + cohort is derived from door parity (odd $497 / even $997), displayed + read-only, never manually selectable, still exported as + `price_cohort`; demo consistency enforced by UI locks (no → result + locked to "not run"; yes → "not run" unselectable, Present/Absent + required; switching clears incompatible state) with a second guard in + submit validation. Verified with a **71/71 Playwright behavioral + pass** covering duplicate rejection, out-of-range rejection, + 21st-entry rejection and completion lock, cohort derivation in UI and + CSV, both invalid demo combinations, persistence, draft restore, CSV + escaping, gated reset, 390px layout, and zero external requests. + +## Guild Command Center built (2026-08-09, authorized) + +- **`playbooks/guild-command-center.html`** — one self-contained, + phone-first cockpit (no libraries, backend, login, or network calls; + sticky bottom nav on phone, rail on desktop): COMMAND (mission, + phases with you-are-here, zero-cost readiness, immediate sequence), + WORKFLOW (8-step door flow with the locked honest PRESENT/ABSENT + wording + fulfillment lanes tagged $497/$997/$149-mo), LOG (the full + field log, same localStorage keys), CLIENT (6-card presentation mode + for the prospect's hands — editable prompt, copyable question, + honest interpretation, tier for the current door, no internal + experiment language), RESULTS (live counts, correct-denominator demo + rate, cohort direction, and a decision gate that never labels a + partial run). Private artifact d2fe8d92-9c56-4a56-bb3a-20f91a8f4cb7. +- **Schema mismatch resolved:** the documented learn gates required + "demo rate among answered owner-present conversations" and "sit-downs + booked" — fields the log couldn't record. Added `owner_present` + (yes/no/not applicable) and `sit_down_booked` (yes/no) with enforced + truth cascades ("no answer" forces N/A + demo not run + sit no; + leaving "no answer" clears the forced values for fresh answers; + sit-down=yes requires a conversation). CSV header + template updated. + Legacy fallback-logger entries migrate non-destructively at read time + (no-answer doors derive their values; answered doors export empty + owner/sit fields and are flagged incomplete by the gate — the run + cannot be judged until every entry is complete). +- **Verification drift fixes (same day):** CLIENT price now follows the + LOG's active door (valid unlogged 1–20) with nextDoor() fallback, so + out-of-sequence doors show the correct locked price; close copy + corrected to the approved "within the week" timeframe, with committed + copy assertions preventing regression. +- **Verified: 130/130 behavioral checks** via the committed suite + `playbooks/tests/command-center-smoke.js`, run at 390×844 and + 1280×800: navigation + client-mode chrome, all prior field-log + integrity behaviors, the new cascades, migration, denominator math, + both complete-run gate verdicts, partial-run no-verdict, tier + mapping, client copy honesty scan, zero external requests, no + horizontal overflow. +- The standalone `lehi-field-log.html` is demoted to EMERGENCY fallback + (pre-revision schema; its entries read incomplete in the gate). + +## PWA production release proof (2026-08-09, Codex deployment) + +- Live at **https://guild-command-center.brandonwadepackard.workers.dev** + — app source commit `ff79f94bceb6b8f3370b81abb2eaf020d38cd7a9`, + Cloudflare Worker upload etag + `da46db5184907c97fe092918e8d6378a6ba42f3384dd4343af6792da610c4c12`. +- Automated proof at that commit: Command Center smoke 130/130 + PWA + smoke 22/22 = **152/152**; `npm audit --omit=dev` 0 vulnerabilities; + secret-shape scan clean. +- Live-asset SHA-256: HTML `3ee0809261cf3ebfe8b0e4f5fba9fa96b3bb4578 + f3b6a485a34701954ae91308` · manifest `dbf2432f33834797c5fa2645dc154b + 3ca123882df7fd48c4a5c1750983d6aa2a` · SW `c0c2f4a9e65eadbb3d5019eae3 + 346f4921ad8d38b606aef81b3b13c1c3d06e0a`. +- Live checks: HTTPS 200; service worker controls; saved state survives + close/reopen and offline reload; zero external requests; unknown + routes 404; POST 405; CSP self-only, X-Frame-Options DENY, no-cache; + `X-Guild-Commit: ff79f94…`. +- Transport: static Cloudflare Worker — no bindings, storage, + analytics, API, payment, CRM, or OpenAI dependency. Public-by-URL; + all field data remains device-local. +- Source of truth stays `playbooks/guild-command-center.html`; `dist/` + stays generated and gitignored. +- **Initial verdict: LIMITED_TEST_READY** until Brandon's actual-iPhone proof + (superseded by the completed device gate below): + Safari Add to Home Screen + airplane-mode open/reload + save/export. + Durable receipt: `pwa/RELEASE-2026-08-09.md`. + +## Next actions (in order) + +1. Joey dry run (lanes 1–3 + 5, timed). +2. Lehi 20-door sprint — field log is the canonical record. +3. Crew decisions before first dollar. + +Not next: any purchase (GHL, number, printing until post-signal), OpenAI +billing, probe calibration, new scripts/tiers/automation. + +## v1.0.1 saved-log retrieval fix (2026-08-10) + +- Brandon reported iPhone install + first save complete, then correctly found + that saved rows were summaries with no open action. This was a confirmed + client UX defect, not evidence of server loss; the app has no server store. +- Source commits: read-only disclosure `30820cf8774b2645264f4642f3cd07913ea28680`; + deterministic offline proof `a46c2ba8145e0414c3263c8ad2e1b448ecc3fe17`. +- Every saved door now opens natively to all recorded fields. The detail view + contains no input, edit, or delete controls, preserving the one-door-once + experiment contract. Version/footer/SW cache advanced to v1.0.1. +- Automated proof: Command Center 137/137 + PWA 27/27 = **164/164**; + three consecutive offline PWA runs passed; audit 0 vulnerabilities; + secret-shape scan clean. +- Live Worker etag `e052dbf494c33e2e7bd862ecd5ad00a16456153afc78bba077bd6acb5fa81b7c`; + HTML SHA-256 `4fb41f047739923855023eba3c7260f0d00f33ded2332e93868a7de0a353f8ae`; + SW SHA-256 `59feb26d312c47d6439718c5706640833f35c2f1f250db337a7857d82790f9af`. +- Fresh live mobile proof passed: v1.0.1 visible, SW control, disclosure starts + closed and opens read-only, state survives close/reopen, details open + offline, zero external requests, unknown route 404, POST 405, and + `X-Guild-Commit: a46c2ba…`. +- **Hotfix verdict remained LIMITED_TEST_READY** until Brandon refreshed the installed + app, opens his saved row, exports, and proves airplane-mode reopen on the + actual phone. No OpenAI/API/GHL/payment work was introduced. + +## Actual-iPhone release gate complete (2026-08-09) + +- Brandon completed the Home Screen install, saved PHONE PROOF, and confirmed + 1/20 on the actual device. +- The v1.0.1 saved row was opened successfully; the captured screen shows the + complete read-only record and no mutation controls. +- CSV export completed to Gmail before destructive cleanup. +- The installed app was closed, reopened in airplane mode, and the saved + record/details still worked. +- The typed RESET gate completed after backup and cleared the test state. +- **Final verdict: RELEASE_READY** for the approved Lehi 20-door field-sprint + scope. The next product proof is the Joey dry run, not more infrastructure. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2800829 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,62 @@ +{ + "name": "guild-command-center", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "guild-command-center", + "version": "1.0.1", + "devDependencies": { + "playwright": "1.62.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c560752 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "guild-command-center", + "private": true, + "version": "1.0.1", + "description": "Offline-first field command center for The Guild's Lehi learn sprint.", + "scripts": { + "build": "bash scripts/build-guild-pwa.sh", + "test:command-center": "node playbooks/tests/command-center-smoke.js", + "test:pwa": "node pwa/tests/pwa-smoke.js", + "test": "npm run test:command-center && npm run test:pwa" + }, + "devDependencies": { + "playwright": "1.62.1" + } +} diff --git a/plans/2026-08-08-build-phase-plan.md b/plans/2026-08-08-build-phase-plan.md index 58b2a6f..661160e 100644 --- a/plans/2026-08-08-build-phase-plan.md +++ b/plans/2026-08-08-build-phase-plan.md @@ -1,82 +1,114 @@ --- date: 2026-08-08 +revised: 2026-08-09 (direction lock — Brandon's confirmed decisions) type: plan project: the-guild status: active -horizon: through first learn sprint (~Aug 22) -evidence: journal/2026-08-08-ai-visibility-sweep-synthesis.md, handoffs/2026-08-07-guild-handoff.md +horizon: through first Lehi learn sprint +evidence: journal/2026-08-08-ai-visibility-sweep-synthesis.md, journal/2026-08-09-direction-lock.md --- -# Guild Build-Phase Plan — 2026-08-08 +# Guild Build-Phase Plan — revised 2026-08-09 -Everything left between today and the first close-rate data. Three phases, -each with owners and pass/fail gates. Doctrine: sell the odds, prove the -delta, never promise the coin flip. +Doctrine: sell the odds, prove the delta, never promise the coin flip. +Customer-facing language is measure → improve → prove; we guarantee only +the work and the report. -## Phase A — Prove the probe (this weekend, Aug 8–10) +## Locked by Brandon, 2026-08-09 (do not reopen) -| # | Item | Owner | Gate | -|---|------|-------|------| -| A1 | Merge PR #4 (sweep + playbook + this plan) | Brandon | merged | -| A2 | Phase 4 reality check via Codex: key into `.env`, 5 real businesses, phone verification | Brandon + Codex | ≥4/5 same direction as phone; franchise scores high; new business ~0 | -| A3 | Review the 10 starter trades in `probe/questions.yaml`; add/cut for our market | Brandon | list matches territory plan | +1. **Business shape:** wedge → ladder → referral flywheel. The small offer + is customer acquisition, not the whole business. +2. **The door demo is ordinary subscription ChatGPT on the prospect's own + phone**, live. The paid Responses-API probe is a separate INTERNAL + baseline/before-after proof lane — it is NOT a pre-door blocker, and no + API key is requested and no paid API calls are made in this phase. +3. Fulfillment playbook v1 and the $497/$997/$149-mo tiers: approved as-is. +4. **First learn-sprint territory: Lehi, Utah.** + +## LEAN CORRECTION (Brandon-verified, 2026-08-09) -If Phase 4 fails calibration: Codex fixes forward (API drift is the likely -cause) — integrity rules are never loosened to make a run pass. Report the -five scores back to a Claude session for journaling either way. +No Guild GoHighLevel account exists — the prior "GHL login/build" launch +prerequisite was a false assumption and is removed from the first-sprint +path. The only live HighLevel access on Brandon's Mac is a +location-scoped integration into a client's sub-account: a separate +client system, **off-limits for Guild use**. Do not create a GHL +account, start a trial, buy a phone number, or create any paid service +in this phase. The first 20 doors run zero-cost. -## Phase B — Build the machine (week of Aug 11) +## NOW — the zero-cost first-sprint path | # | Item | Owner | Gate | |---|------|-------|------| -| B1 | **Territory decision** — pick the starting town(s). Blocks C entirely; decide by Mon Aug 11 | Brandon | named territory written down | -| B2 | GHL sealed funnel: AI Employee ($97/mo) inbound on the Guild line; consent language on every form/QR; <60s callback on consented leads | Brandon | test call answered + booking lands + consent stored | -| B3 | Price card print: $497/$997/$149-mo, fee-credit mechanic, honest-line footer (content locked since 8/7) | Brandon | cards in hand | -| B4 | Playbook dry run: Joey reads playbook v1, then Step 0 + Lanes 1–3 + 5 on one friendly business, timing each lane | Joey + Brandon | full checklist done once; hours-per-client number written down | -| B5 | Crew decisions: rev split across the seven; referral reward now vs later | Brandon + crew | decided before first outside dollar | - -The B4 timing number matters: it converts the playbook into capacity math -(clients per week Joey can fulfill) before we promise anything at doors. - -## Phase C — Learn sprint (week of Aug 17, or when A2 + B1–B3 pass) - -- 20-door week in the chosen territory. Cohorts: 10 doors pitched at $497, - 10 at $997. Element read on every doorstep; scripts by the book; every - objection logged in GHL with the door's element. -- Every close: consent captured on the spot → funnel takes over; baseline - probe run same day → proof clock starts. -- Output (the whole point): close-rate by price point + objection log. - This is data nobody else in the market has. It confirms or resets pricing, - and the objections feed script edits by panel ID. - -## Standing lane (parallel, no deadline) - -- Install the three mined skills on the Mac (`appearance-rate-probe` first, - then `four-elements-sales-scripts`, `cold-outreach-legality-gate`). -- 90-day re-probe calendar reminders — starts with customer #1. -- Account consolidation (`abcnuts` vs `-cell`) — someday; needs `abcnuts` - credentials; blocks nothing. -- Clinics vertical — parked until door data exists (AI hedges health - queries; entry route is the directory layer). +| N1 | Command Center on the phone: open `playbooks/guild-command-center.html` (private artifact d2fe8d92-…), save a test door in LOG, export CSV, reset | Brandon | test entry saved + CSV exported, then reset | +| N2 | Joey dry run: playbook lanes 1–3 + 5 on one friendly business, timed (Step 0's probe baseline is deferred with the probe lane) | Joey + Brandon | checklist done once; hours-per-client written down | +| N3 | Crew decisions: rev split across the seven; referral reward now vs later — the flywheel needs the referral answer | Brandon + crew | decided before first outside dollar | + +## SPRINT — Lehi, Utah (ready now; needs only a phone and doors) + +Full sprint plan: `plans/2026-08-09-lehi-learn-sprint.md` — first-wave +scope, trade cohorts, the ChatGPT-on-their-phone demo procedure, the +recording spec, and pass/fail evidence after the first reps. The +**Guild Command Center** (`playbooks/guild-command-center.html`) is the +canonical first-sprint surface — plan, workflow, log, client +presentation, and results in one offline file; its LOG view is the +canonical record. `playbooks/lehi-field-log.html` is an emergency +fallback only. + +## POST-SIGNAL INFRASTRUCTURE (procure after a close, not before) + +- GHL (or equivalent) funnel: a purchasing decision AFTER the first + close. `playbooks/ghl-funnel-build-sheet.md` is the ready spec for + that day (consent language, test protocol) — spec, not prerequisite. +- Price cards + dedicated line/QR: post-signal. The card source + (`playbooks/price-card-print.html`) stays print-ready with + placeholders until a number exists. + +## DEFERRED LANE — internal probe (activates at first close) + +The probe repo is live and healthy: `brandonwadepackard-cell/ +guild-visibility-probe` (private), `main` at +`0e455bfd200da0d3f64892c6f6915de9cb77d882`, default model +`gpt-5.6-terra`, tests 8/8, Lehi wave-one trades in `questions.yaml` +(state confirmed by Brandon 2026-08-09). Search-required and invalid-run +protections stay as-is. + +- Trigger: **first close** (a customer baseline is the first real need) or + Brandon explicitly activating it. Not before. +- Until then: no API key requests, no paid API calls, no calibration gate. +- When triggered: Codex runs the calibration + first baselines per + `handoffs/2026-08-08-codex-phase4-handoff.md` (revised). + +## Standing lane (no deadline) + +- Codex Mac lane: ✅ COMPLETE (Lehi trades list @ `0e455bfd`) — no + remaining Codex launch task. Skill creation/install stays deferred + (reference only, not a launch item); any future skill work uses the + canonical `~/Projects/skills` store, dedupes once, canonical backup + workflow. +- 90-day re-probe reminders — starts with customer #1. +- Account consolidation (`abcnuts` vs `-cell`) — someday; blocks nothing. +- Clinics vertical — parked until door data exists. ## Ownership map -- **Brandon:** merges, decisions (territory, splits, referral), GHL build, - print, doors. -- **Joey:** fulfillment playbook mastery + dry run + capacity number. -- **Codex (Mac):** Phase 4 run; probe fixes if API drifted. -- **Claude sessions:** journaling, research sweeps on demand, script-book - edits by panel ID, next-session continuity. Cannot: create repos, touch - `-cell` account, run the probe (no key in cloud). +- **Brandon:** doors, crew decisions; post-signal procurement (funnel, + number, print) after the first close. +- **Joey:** playbook mastery, dry run, capacity number, fulfillment. +- **Codex (Mac):** trades list, skills dedupe; probe lane when activated. +- **Claude sessions:** journaling, sweeps on demand, doc/plan upkeep, + continuity. Cannot: printing, doors, `-cell` repo access, any paid + procurement. ## Not in this plan, on purpose -DIY voice stack · AI voice/SMS/RVM to cold lists (legality gate) · schema -"magic" and ranking guarantees (evidence: sweep) · probe v2 features · -FLEXX work · clinics · account consolidation · new tooling purchases. +DIY voice stack · AI voice/SMS/RVM to cold lists (legality gate) · +schema "magic" or any guaranteed-placement language · new scripts, tiers, +legal architecture, or automation beyond what is approved · probe v2 +features · FLEXX work · clinics · account consolidation · new tooling. ## Re-entry points for the next session -1. "Phase 4 results: …" → journal the scores, update this plan. -2. "Territory is X" → finalize trades list, sprint checklist. -3. "Sprint done" → skill-mine the objection log; pricing verdict. +1. "Sprint results: …" (+ field-log CSV) → mine the objection log; + pricing verdict; wave-two cohorts. +2. "First close" → activates BOTH deferred lanes: the probe (calibration + + customer baseline via Codex) and the funnel procurement decision. diff --git a/plans/2026-08-09-lehi-learn-sprint.md b/plans/2026-08-09-lehi-learn-sprint.md new file mode 100644 index 0000000..de857c8 --- /dev/null +++ b/plans/2026-08-09-lehi-learn-sprint.md @@ -0,0 +1,156 @@ +--- +date: 2026-08-09 +type: sprint-plan +project: the-guild +status: ready-now-zero-cost +territory: Lehi, Utah +prerequisites: a phone with subscription ChatGPT + the Guild Command Center (playbooks/guild-command-center.html) — no paid infrastructure +--- + +# Lehi Learn Sprint — First-Wave Plan + +Twenty doors in Lehi, Utah. The output is data: close-rate by price point +and the objection log. Doctrine on every doorstep: measure → improve → +prove; guarantee only the work and the report. + +## Why Lehi plays differently (walk in knowing this) + +Lehi sits in Utah County — the home turf of the American door-to-door +industry (pest control, alarms, solar all recruit and sell here). Two +consequences: + +1. **Owners are pitch-literate.** Expect a high share of WATER reads + (been-burned, arms crossed) and fast pattern-matching to "another + door guy." The honest no-guarantee line is the pattern-break — lead + with what we won't promise sooner than the script book's default. +2. **Fast-growth market.** New rooftops and new businesses every quarter + mean AI answers churn — being measured beats being established. That's + the FIRE/AIR angle for newer shops competing with legacy names. + +## First-wave scope + +- **20 doors, one week.** Cohorts locked: 10 pitched at $497, 10 at $997, + assigned by alternating door BEFORE the knock — never by the driveway. +- Geography: Lehi city proper — Main Street / State Street corridor for + storefronts, plus the trades' shops and yards off Pioneer Crossing and + SR-92 approaches. Skip Silicon Slopes office towers — wrong buyer. +- Every door logged, including no-answers. The dataset only works + complete — same integrity rule as everything else. + +## Trade cohorts (first wave) + +Home services first — highest "who do I call" AI-query fit, dense in +Utah County, owner reachable at the shop: + +| Priority | Trades | Why | +|---|---|---| +| 1 | HVAC · plumbing · electrical | Emergency "who do I call" queries; hot/cold climate; owner-operated shops | +| 2 | Sprinkler/landscape · pest control · garage door | Utah-dense trades; seasonal urgency now (August) | +| 3 | Roofing · auto repair · barber | Roofers post-storm; walk-in retail for demo practice | + +Dentists/chiropractors/restaurants: hold for wave two — clinic-adjacent +AI hedging and lunch-rush timing complicate the first reps. (Trades list +in `probe/questions.yaml` gets tuned to this table — Codex Mac lane.) + +## The door demo — ChatGPT on THEIR phone (locked procedure) + +The demo is ordinary subscription ChatGPT, live, their thumbs. No API, +no probe, no staging. Best case their phone; Brandon's phone is the +fallback if they won't install or don't have it. + +1. Element read first 30 seconds (script book governs the pitch itself). +2. Ask permission: "Grab your phone — I want to show you something about + your business, takes 45 seconds." +3. They open ChatGPT (App Store install if needed and they're willing; + otherwise fallback phone). Web search on if the toggle is visible. +4. **They type or dictate:** "Who should I call for a [their trade] in + Lehi, Utah?" — their trade, exact wording, their thumbs on the send. +5. Read the answer together, out loud, whatever it says. + +Handling the two outcomes — honestly, per doctrine: + +- **They're not in the answer:** "That's who ChatGPT named in this + answer instead of you. And here's the honest part — one ask is one + flip of the coin. + We measure it properly: ten separate asks, counted. Then we do the + work, measure again in ninety days, and you hold both reports." +- **They ARE in the answer:** "Good — today's flip went your way. It + doesn't always; the answer changes ask to ask. We measure the real + rate, and the monthly plan is how we keep doing the work and + remeasure as it changes." + +Forbidden at the door: re-rolling the demo until it "shows the right +thing" · promising the demo will or won't show anyone · any wording that +implies we make the AI say their name or guarantee placement or score. +The demo shows what the AI says today; the offer is measure → improve → +prove. + +## What to record (canonical: the Command Center LOG view, per door, 60 seconds) + +**`playbooks/guild-command-center.html` is the canonical first-sprint +surface** — plan, door workflow, field log, client presentation, and +live results in one offline file (private artifact +d2fe8d92-9c56-4a56-bb3a-20f91a8f4cb7). Its LOG view is the canonical +record for the first 20 doors: offline, localStorage autosave, progress +out of 20, escaped CSV export (`playbooks/lehi-field-log-template.csv` +is the same header as a paper/spreadsheet fallback). Fields per door: + +- door number · timestamp (auto) · business name · trade +- price_cohort ($497/$997, derived from door parity — locked) +- outcome (closed / callback set / soft no / hard no / no answer) +- **owner_present (yes / no / not applicable)** +- demo_shown (y/n) · **demo_result: present / absent / not run** +- **sit_down_booked (yes / no)** +- element read (Fire/Earth/Water/Air/unread) · notes with objection + verbatim · optional callback date + +Truth rules the log enforces: "no answer" forces owner_present=N/A, +demo_shown=no, demo_result=not run, sit_down_booked=no; a sit-down can +only be "yes" when a conversation happened; a shown demo must record +Present or Absent, never "not run." + +No customer PII goes in the log — business info only. Export the CSV at +the end of each day. `playbooks/lehi-field-log.html` remains an +EMERGENCY fallback only: it predates the owner/sit-down fields, and +entries logged there will read as incomplete in the results gate. + +## On a close + +- Consent captured at the table using the approved verbatim consent + language (`playbooks/ghl-funnel-build-sheet.md` §3) on a simple form — + paper or a notes form on their phone; keep the record. Follow-up is + human and manual until infrastructure exists. +- **A close is the procurement trigger.** GHL (or equivalent) becomes a + post-validation purchasing decision AFTER the first close — the build + sheet is the ready spec for that day. It is not a launch prerequisite. +- Fulfillment starts per approved playbook v1. The internal probe + baseline is part of the proof lane — **first close is what activates + that lane** (Brandon triggers it; until then no API key, no paid calls). + Promise the customer a measured baseline "within the week," which the + activated lane comfortably meets. + +## Pass/fail evidence after the first reps + +Hard gate (sprint validity): **all 20 doors logged, complete fields** — +an incomplete dataset is an invalid run, same as the probe. + +The Command Center's RESULTS view computes these gates automatically +with the correct denominators, and refuses any verdict until the run is +complete — a partial or incomplete run is never labeled pass or fail. + +Read after 20 doors (learn gates, not success guarantees): + +- **Demo lands:** demo_shown on ≥50% of real conversations (door answered, + owner present). Below that → the ask-permission line needs work before + more doors, not more doors. +- **Offer signal:** ≥3 sit-downs booked or ≥1 close across 20 doors → + continue to the next 20 at the same prices. Zero sits AND zero closes → + stop, mine the objection log with a Claude session before spending + more doors. +- **Cohort read:** close/sit rate by $497 vs $997 cohort + objection mix + by element → the pricing verdict input. 20 doors is a small sample; + treat differences as direction, not proof — the next 20 confirm. + +Bring "Sprint results: …" with the field-log CSV to a Claude session and +it gets mined: pricing verdict, script edits by panel ID, wave-two +cohorts. diff --git a/playbooks/ghl-funnel-build-sheet.md b/playbooks/ghl-funnel-build-sheet.md index 6a9635f..85692ce 100644 --- a/playbooks/ghl-funnel-build-sheet.md +++ b/playbooks/ghl-funnel-build-sheet.md @@ -2,12 +2,18 @@ date: 2026-08-08 type: build-sheet project: the-guild -status: ready-to-build -plan-item: B2 (with C sprint-kit appendix) -owner: Brandon (build), Claude (spec) +status: spec only — procurement deferred until after a close (2026-08-09 lean correction) +plan-item: post-signal infrastructure +owner: Brandon (build, when procured), Claude (spec) --- -# GHL Sealed Funnel — Build Sheet (Plan item B2) +# GHL Sealed Funnel — Build Sheet (post-signal spec) + +> **Status (2026-08-09):** No Guild GHL account exists; this sheet is +> the ready spec for the procurement decision that follows the first +> close. It is NOT a launch prerequisite. The first 20 doors run +> zero-cost with the offline field log. The §3 consent language is used +> at closes now (paper or phone form). Everything to configure, in order, with the exact consent language. Gate: test call answered + booking lands + consent stored. diff --git a/playbooks/guild-command-center.html b/playbooks/guild-command-center.html new file mode 100644 index 0000000..aa02e07 --- /dev/null +++ b/playbooks/guild-command-center.html @@ -0,0 +1,1347 @@ + + + + + + + + + + + + + +Guild Command Center + + + + +
+ LEHI PILOT + 0 / 20 + Prepare + +
+ +
+
+ A newer Command Center is ready. + +
+ + +
+

Command

+

Measure → improve → prove. Sell the odds, prove the delta, never promise the coin flip. We guarantee only the work and the report.

+ +
+

Mission: The Guild sells local businesses a measured AI appearance rate — the wedge. The small offer buys a customer; the ladder and the referral flywheel build the business. First market: Lehi, Utah, twenty doors, zero cost.

+
+ +

This app on your phone

+
+
Checking offline readiness…
+
+ +
+

On iPhone: open this page in Safari, tap Share, then Add to Home Screen.

+
+ +

What happens next

+
+
Prepare
Phone check · field log test · Joey dry run
+
20-door learn sprint
Lehi · alternating price cohorts · every door logged
+
First close
Consent on paper · triggers baseline + procurement decisions
+
Baseline + fulfillment
Internal probe baseline · playbook lanes
+
90-day remeasure
Same question, same count — the before/after they hold
+
Referral flywheel
Proven customers recruit the next doors
+
+ +

Ready now — zero cost

+
+
+ Subscription ChatGPT + A phone + This field log + Physical doors +
+
+ Funnel — after first close + Probe baseline — after first close + Printed cards — after a number exists +
+

The second row isn't missing — it's deliberately parked until a close proves the offer.

+
+ +

Immediate sequence

+
+
    +
  1. Phone check: save a test door in LOG, export the CSV, reset.
  2. +
  3. Joey dry run: fulfillment lanes 1–3 + 5 on a friendly business, timed.
  4. +
  5. Twenty doors in Lehi — the WORKFLOW view is the script.
  6. +
  7. Crew decision before the first dollar: rev split + referral reward.
  8. +
+
+
+ + +
+

Workflow

+

The door, step by step

+ +
1Approach + element read +

First 30 seconds: read the buyer — Fire (rival on the brain), Earth (wants paper), Water (been burned), Air (curious, phone out). The script book governs the pitch itself.

+
+
2Ask permission +

"Grab your phone — I want to show you something about your business. Takes 45 seconds."

+
+
3They open ChatGPT +

Their phone, their thumbs. If they don't have it or won't install: your phone is the fallback. Web search on if the toggle is visible. Switch to the CLIENT view and hand them the screen.

+
+
4Ask the real local question +

"Who should I call for a [their trade] in Lehi, Utah?"

Their trade, exact wording, they hit send. Never re-roll the answer.

+
+
5Record what happened +

Present, absent, or not run — it goes in the LOG exactly as it happened. The demo shows what the AI says today; nothing more.

+
+
6Explain it honestly +
+

If they're ABSENT:

+

"Today, ChatGPT named other businesses in this answer — not you. And here's the honest part: one ask is one flip of the coin. The answer changes from ask to ask. We measure the real number — ten separate asks, counted — do the work, and measure again in ninety days. You hold both reports."

+

If they're PRESENT:

+

"Good — today's answer went your way. It doesn't always: the same question gets different answers ask to ask. We measure your real rate, keep doing the work, and remeasure as it changes — so you're not betting on one lucky answer."

+

One answer never proves a ranking, a rate, or where calls go. Only the sampled number is real.

+
+
+
7Offer the locked price +

The CLIENT view shows this door's price and what's included. Say the number straight the moment price comes up. No guarantee of placement or score — the work and the reports are what's promised.

+
+
8Log outcome + follow-up +

Outcome, owner present, sit-down booked, element, their exact objection words, callback date if any. On a close: consent language on paper, and fulfillment begins per the playbook below.

+
+ +

After a close — fulfillment lanes (playbook v1)

+
+
$497 + $997Intake + internal baseline measurement (probe lane activates at first close)
+
$497 + $997Google Business Profile: claim, complete every field, photos, respond to reviews
+
$497 + $997Yelp: claim + complete + respond — never solicit Yelp reviews (policy)
+
$497 + $997ChatGPT stack: Foursquare claim, Bing Places via GBP import, Bing Webmaster Tools, crawlability
+
$497 + $997NAP consistency sweep: one canonical name/address/phone everywhere
+
$997Review engine — Google-first asks, no incentives, no gating, FTC-clean
+
$997Website + local mentions: service-town pages, best-of list pitches
+
$997Midpoint check + printed before/after report
+
$497 + $99790-day remeasure with the locked baseline config
+
$149/moStay Visible: monthly measurement, every review answered, listing patrol, quarterly refresh
+

Setup fee credits toward the first month. We do not sell schema markup as AI magic, and we never guarantee a ranking, placement, or score.

+
+
+ + +
+

Field Log — 20 Doors

+

Offline. Saves on this device only. Every door 1–20 logged exactly once, including no-answers. Export CSV at day's end.

+ +
0 / 20 doors
+
$497: 0 · $997: 0 · closed: 0
+
SPRINT COMPLETE — all 20 doors logged. See RESULTS and export the CSV.
+ +
+
+ This door +
+
+ + +
+
+ + +
+
+ + + + + +
$497
+ + +
+ + + + + +
+ + +
+ + + +
+
N/A applies only to no-answer doors.
+ + +
+ + +
+ + +
+ + + +
+
Pick "Demo shown" first — result options follow it.
+ + +
+ + +
+ + +
+ + + + + +
+ + + + + + + +

Business info only — no customer names, numbers, or personal details in this log.

+
+ +
+
+ +

Logged doors

+

Tap a saved door to view every recorded detail. Saved doors are read-only.

+

Nothing yet. First door of twenty.

+ +
+ + + +
+

+

Restore replaces this device's current log. Export the current log first if you need both copies.

+
+ + +
+
+
+ Reset (erases ALL entries) +

This deletes every logged door from this device permanently. Export the CSV first. Type RESET to arm the button.

+ + +
+
+
Guild Command Center v1.0.1 · local device storage · every door logged once, no cherry-picking
+
+ + +
+

Results

+
+
RUN INCOMPLETE
+

Gates evaluate only on a complete run: all 20 unique doors with complete entries. Partial data never produces a pass or fail.

+
+ +

Counts

+
+ +

Demo rate (correct denominator)

+
+ +

Outcomes

+
+ +

Cohorts — direction only, not proof

+
+ +

Trades + elements

+
+ +

Callbacks due

+
+ +
+ +
+
+ +
+ + +
+ +
+
STEP 1 OF 6
+ +
+

Let's ask your ChatGPT.

+

Not ours — yours. Same app your customers use.

+ + + + + + +
+
+ +
+

Type this in — your thumbs.

+
Who should I call for a plumber in Lehi, Utah?
+ +

+
+
+ +
+

What did it say?

+

Read the answer together. Is your business in it?

+
+ + +
+
+
+ +
+

Here's the honest read.

+

+

What we sell is the real number: we ask ten separate times, count how often you're named, do the work, then measure again in ninety days. You hold both reports.

+
+
+ +
+

What we improve.

+

The places the AI actually reads about you:

+
+ Google profileYelpBing + FoursquareAppleYour website + Reviews, done legallyWrong info fixed +
+

Claimed, corrected, complete — then measured again.

+
+
+ +
+

One flat price. No contract on setup.

+
$497 setup
+
    +

    $149/month after keeps it measured and maintained: monthly check, every review answered, listing patrol. Your setup fee credits toward the first month.

    +

    If that's fair, we can start this week — I'll take your business info and the work begins. You'll have the first measured report in hand within the week.

    +

    "Anyone who guarantees a ranking is lying to you. I guarantee the work — and a before/after report you can hold."

    +
    +
    +
    +
    + + + + + + diff --git a/playbooks/lehi-field-log-template.csv b/playbooks/lehi-field-log-template.csv new file mode 100644 index 0000000..414bc6e --- /dev/null +++ b/playbooks/lehi-field-log-template.csv @@ -0,0 +1 @@ +door_number,timestamp,business_name,trade,price_cohort,owner_present,demo_shown,demo_result,outcome,sit_down_booked,objection_element,notes,callback_date diff --git a/playbooks/lehi-field-log.html b/playbooks/lehi-field-log.html new file mode 100644 index 0000000..571d47e --- /dev/null +++ b/playbooks/lehi-field-log.html @@ -0,0 +1,450 @@ + + + + + +Lehi Field Log — 20 Doors + + + +

    Lehi Field Log — 20 Doors

    +

    Offline. Saves on this device only. Export CSV at day's end. Every door 1–20 gets logged exactly once, including no-answers.

    + +
    0 / 20 doors
    +
    $497: 0 · $997: 0 · closed: 0
    +
    SPRINT COMPLETE — all 20 doors logged. Export the CSV.
    + +
    +
    + This door +
    +
    + + +
    +
    + + +
    +
    + + + + + +
    $497
    + + +
    + + +
    + + +
    + + + +
    +
    Pick "Demo shown" first — result options follow it.
    + + +
    + + + + + +
    + + +
    + + + + + +
    + + + + + + + +

    Business info only — no customer names, numbers, or personal details in this log.

    +
    + +
    +
    + +

    Logged doors

    +

    Nothing yet. First door of twenty.

    + +
    + +
    + Reset (erases ALL entries) +

    This deletes every logged door from this device permanently. Export the CSV first. Type RESET to arm the button.

    + + +
    +
    + + + + + + diff --git a/playbooks/price-card-print.html b/playbooks/price-card-print.html index b5b1320..e5b48a5 100644 --- a/playbooks/price-card-print.html +++ b/playbooks/price-card-print.html @@ -139,7 +139,7 @@

    Same question, ten asks, 90 days later. Two reports side by side. You keep both.

    -
    45% of people now ask AI to find local businesses — up from 6% a year ago. The AI recommends about 1 business in 50. We work to make it yours.
    +
    45% of people now ask AI to find local businesses — up from 6% a year ago. The AI recommends about 1 business in 50. We measure where you stand, do the work, and prove what changed.
    The Guild
    diff --git a/playbooks/tests/command-center-smoke.js b/playbooks/tests/command-center-smoke.js new file mode 100644 index 0000000..8b2f913 --- /dev/null +++ b/playbooks/tests/command-center-smoke.js @@ -0,0 +1,387 @@ +/* + * Behavioral smoke suite for playbooks/guild-command-center.html + * + * Run: npm install playwright (Chromium required; uses /opt/pw-browsers/chromium + * when present, otherwise Playwright's own browser) + * node playbooks/tests/command-center-smoke.js + * + * Proves: view navigation (incl. client presentation mode), zero external + * requests, no horizontal overflow at 390px and desktop, the full field-log + * integrity contract (doors 1-20 unique, duplicate/out-of-range/21st-entry + * rejection, completion lock, derived cohorts, demo-consistency locks, draft + * + reload persistence, escaped CSV, typed-RESET gate), the owner_present / + * sit_down_booked schema with its truth cascades, legacy-entry migration, + * the RESULTS denominator (demos among answered owner-present conversations), + * partial-run INVALID, both 20-door gate verdicts, client-view honesty copy, + * tier mapping, and a customer-facing guarantee-language scan. + */ +const { chromium } = require("playwright"); +const path = require("path"); +const fs = require("fs"); + +const FILE = "file://" + path.resolve(__dirname, "..", "guild-command-center.html"); +const BROWSER_CANDIDATES = [ + "/opt/pw-browsers/chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +]; +const results = []; +function check(name, ok, detail) { + results.push((ok ? "PASS" : "FAIL") + " " + name + (detail ? " (" + detail + ")" : "")); + if (!ok) process.exitCode = 1; +} + +function entry(door, over) { + const base = { + door_number: door, timestamp: "2026-08-10 09:00", business_name: "Biz " + door, + trade: "hvac", price_cohort: door % 2 === 1 ? "$497" : "$997", + owner_present: "yes", demo_shown: "yes", demo_result: "absent", + outcome: "soft no", sit_down_booked: "no", objection_element: "water", + notes: "", callback_date: "" + }; + return Object.assign(base, over || {}); +} +function noAnswer(door) { + return entry(door, { owner_present: "not applicable", demo_shown: "no", demo_result: "not run", outcome: "no answer", sit_down_booked: "no", objection_element: "unread" }); +} +async function seed(page, entries) { + await page.evaluate((data) => { + localStorage.setItem("lehi-field-log-v1", JSON.stringify(data)); + localStorage.removeItem("lehi-field-log-draft-v1"); + }, entries); + await page.reload(); +} +async function nav(page, view) { await page.click(`#tabbar button[data-view="${view}"]`); } + +async function saveDoor(page, o) { + if (o.door != null) await page.fill("#door", String(o.door)); + await page.selectOption("#trade", o.trade || "plumber"); + if (o.biz) await page.fill("#biz", o.biz); + await page.click(`#outcome button[data-v="${o.outcome || "soft no"}"]`); + if ((o.outcome || "soft no") !== "no answer") { + await page.click(`#ownerPresent button[data-v="${o.owner || "yes"}"]`); + await page.click(`#demoShown button[data-v="${o.demoShown || "yes"}"]`); + if ((o.demoShown || "yes") === "yes") await page.click(`#demoResult button[data-v="${o.demoResult || "absent"}"]`); + await page.click(`#sitDown button[data-v="${o.sit || "no"}"]`); + } + await page.click(`#element button[data-v="${o.element || "water"}"]`); + if (o.notes) await page.fill("#notes", o.notes); + if (o.cb) await page.fill("#cb", o.cb); + await page.click("#saveBtn"); +} + +(async () => { + const executablePath = BROWSER_CANDIDATES.find(candidate => fs.existsSync(candidate)); + const launchOpts = executablePath ? { executablePath } : {}; + const browser = await chromium.launch(launchOpts); + + /* ================= MOBILE (390px) — full suite ================= */ + const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, acceptDownloads: true }); + const page = await ctx.newPage(); + const external = []; + page.on("request", (r) => { if (!r.url().startsWith("file://")) external.push(r.url()); }); + await page.goto(FILE); + check("m: page loads with title", (await page.title()) === "Guild Command Center"); + + // ---- Top status + navigation ---- + for (const id of ["topProgress","topPhase","nextMoveBtn","tabbar"]) { + check("m: #" + id + " present", await page.locator("#" + id).count() === 1); + } + check("m: starts on COMMAND", await page.locator("#view-command").evaluate(v => v.classList.contains("on"))); + check("m: phase pill starts Prepare", (await page.locator("#topPhase").textContent()) === "Prepare"); + check("m: next move says START DOOR 1", (await page.locator("#nextMoveBtn").textContent()).includes("START DOOR 1")); + check("m: COMMAND marks Prepare as current", await page.locator('.phase[data-phase="0"] .youarehere').count() === 1); + check("m: post-close chips labeled later", (await page.locator(".chip.later").count()) === 3); + + for (const v of ["workflow","log","results","command"]) { + await nav(page, v); + check("m: nav to " + v.toUpperCase(), await page.locator("#view-" + v).evaluate(el => el.classList.contains("on"))); + } + check("m: no page navigation occurred", page.url().startsWith("file://")); + + // NEXT MOVE routes to LOG when incomplete + await page.click("#nextMoveBtn"); + check("m: NEXT MOVE routes to LOG", await page.locator("#view-log").evaluate(v => v.classList.contains("on"))); + + // ---- CLIENT presentation mode ---- + await nav(page, "client"); + check("m: client view on", await page.locator("#view-client").evaluate(v => v.classList.contains("on"))); + check("m: client mode hides tabbar", await page.locator("#tabbar").evaluate(t => getComputedStyle(t).display === "none")); + check("m: client mode hides topbar", await page.locator(".topbar").evaluate(t => getComputedStyle(t).display === "none")); + check("m: client price maps door 1 → $497", (await page.locator("#cPrice").textContent()).includes("$497")); + // Active-door regression: CLIENT must follow the LOG's selected door, not + // merely nextDoor() — matters when doors are worked out of sequence. + await page.click("#clientExit"); + await nav(page, "log"); + await page.fill("#door", "2"); + await nav(page, "client"); + check("m: active door 2 (no saves) → CLIENT $997", (await page.locator("#cPrice").textContent()).includes("$997")); + await page.click("#clientExit"); + await nav(page, "log"); + await page.fill("#door", "5"); + await nav(page, "client"); + check("m: active door 5 (no saves) → CLIENT $497", (await page.locator("#cPrice").textContent()).includes("$497")); + await page.click("#clientExit"); + await nav(page, "log"); + await page.fill("#door", "1"); + await nav(page, "client"); + await page.selectOption("#cTrade", "pest control company"); + await page.fill("#cTown", "Lehi, Utah"); + await page.click("#cNext0"); + check("m: prompt uses trade + town", (await page.locator("#cPrompt").textContent()) === "Who should I call for a pest control company in Lehi, Utah?"); + await page.click("#cCopy"); + const copyFeedback = await page.waitForFunction( + () => (document.getElementById("cCopyMsg").textContent || "").length > 0, null, { timeout: 5000 } + ).then(() => true, () => false); + check("m: copy gives feedback", copyFeedback); + await page.click("#cNext1"); + await page.click("#cAbsent"); + const absentText = await page.locator("#cInterpBody").textContent(); + check("m: ABSENT copy says one answer proves nothing", absentText.includes("one flip of the coin") && absentText.includes("doesn't prove")); + await page.click("#cBack3"); + await page.click("#cPresent"); + const presentText = await page.locator("#cInterpBody").textContent(); + check("m: PRESENT copy warns it doesn't always", presentText.includes("doesn't always") && presentText.includes("never proves")); + await page.click("#cNext3"); await page.click("#cNext4"); + check("m: $497 tier lists 4 inclusions", (await page.locator("#cIncludes li").count()) === 4); + check("m: honest line present on offer card", (await page.locator(".honest-foot").textContent()).includes("guarantee the work")); + const closeCopy = await page.locator("#cClose").textContent(); + check("m: close copy promises the approved timeframe", closeCopy.includes("within the week")); + check("m: close copy does not say within days", !/within days/i.test(closeCopy)); + check("m: no cohort/experiment language in client view", !/cohort|experiment|odd door|even door/i.test(await page.locator("#view-client").innerText())); + await page.click("#clientExit"); + check("m: client exit returns to operator", await page.locator("#view-command").evaluate(v => v.classList.contains("on"))); + + // ---- Customer-facing copy scan (guarantee violations) ---- + const clientCopy = await page.locator("#view-client").innerText(); + const scrubbed = clientCopy.replace(/Anyone who guarantees a ranking is lying to you\. I guarantee the work[^"]*"/g, ""); + check("m: client copy has no guarantee violations", + !/(we (?:will |’ll |'ll )?make (?:the )?AI|guarantee[sd]? (?:a |your )?(?:rank|placement|score|result|spot)|you will (?:show up|appear|rank)|#1 in ChatGPT)/i.test(scrubbed)); + + // ---- LOG: structure ---- + await nav(page, "log"); + for (const id of ["door","trade","biz","notes","cb","cohortDisplay","outcome","ownerPresent","demoShown","demoResult","sitDown","element","saveBtn","exportBtn","resetBtn","resetConfirm","completeBanner"]) { + check("m: log #" + id + " present", await page.locator("#" + id).count() === 1); + } + check("m: trades include wave-one set", (await page.locator("#trade option").allTextContents()).join("|").includes("sprinkler")); + check("m: door bounded 1-20 in markup", await page.locator('#door[min="1"][max="20"]').count() === 1); + check("m: no manual cohort control", (await page.locator("#cohortDisplay button").count()) === 0); + check("m: door 1 derives $497", (await page.locator("#cohortDisplay").textContent()).includes("$497")); + await page.fill("#door", "2"); + check("m: door 2 derives $997", (await page.locator("#cohortDisplay").textContent()).includes("$997")); + await page.fill("#door", "1"); + + // ---- Cascades ---- + check("m: owner N/A disabled by default", await page.locator('#ownerPresent button[data-v="not applicable"]').isDisabled()); + check("m: demo result locked before demoShown", await page.locator('#demoResult button[data-v="present"]').isDisabled()); + await page.click('#outcome button[data-v="no answer"]'); + check("m: no-answer forces owner N/A", await page.locator('#ownerPresent button[data-v="not applicable"]').evaluate(b => b.classList.contains("on"))); + check("m: no-answer locks owner group", await page.locator('#ownerPresent button[data-v="yes"]').isDisabled()); + check("m: no-answer forces demo no", await page.locator('#demoShown button[data-v="no"]').evaluate(b => b.classList.contains("on"))); + check("m: no-answer forces not run", await page.locator('#demoResult button[data-v="not run"]').evaluate(b => b.classList.contains("on"))); + check("m: no-answer forces sit no", await page.locator('#sitDown button[data-v="no"]').evaluate(b => b.classList.contains("on"))); + check("m: no-answer locks sit group", await page.locator('#sitDown button[data-v="yes"]').isDisabled()); + await page.click('#outcome button[data-v="soft no"]'); + check("m: leaving no-answer unlocks owner", !(await page.locator('#ownerPresent button[data-v="yes"]').isDisabled())); + check("m: leaving no-answer clears owner N/A", await page.locator('#ownerPresent button[data-v="not applicable"]').evaluate(b => !b.classList.contains("on"))); + check("m: leaving no-answer unlocks sit", !(await page.locator('#sitDown button[data-v="yes"]').isDisabled())); + await page.click('#demoShown button[data-v="no"]'); + check("m: demo=no forces not run", await page.locator('#demoResult button[data-v="not run"]').evaluate(b => b.classList.contains("on"))); + check("m: demo=no locks present", await page.locator('#demoResult button[data-v="present"]').isDisabled()); + await page.click('#demoShown button[data-v="yes"]'); + check("m: switch to yes clears not-run", await page.locator('#demoResult button[data-v="not run"]').evaluate(b => !b.classList.contains("on"))); + check("m: yes disables not-run", await page.locator('#demoResult button[data-v="not run"]').isDisabled()); + + // ---- Validation guards ---- + await page.click('#ownerPresent button[data-v="yes"]'); + await page.click('#outcome button[data-v="soft no"]'); + await page.selectOption("#trade", "plumber"); + await page.click('#element button[data-v="water"]'); + await page.click("#saveBtn"); // demo=yes but no result picked + check("m: yes without result rejected", (await page.locator(".msg.err").textContent()).includes("Present or Absent")); + await page.click('#demoResult button[data-v="absent"]'); + await page.click("#saveBtn"); // sit-down not picked + check("m: missing sit-down rejected", (await page.locator(".msg.err").textContent()).includes("Sit-down")); + await page.fill("#door", "0"); + await page.click('#sitDown button[data-v="no"]'); + await page.click("#saveBtn"); + check("m: door 0 rejected", (await page.locator(".msg.err").textContent()).includes("1–20")); + await page.fill("#door", "21"); + await page.click("#saveBtn"); + check("m: door 21 rejected", (await page.locator(".msg.err").textContent()).includes("1–20")); + check("m: no entries after rejects", (await page.locator("#barLabel").textContent()).trim() === "0 / 20 doors"); + + // ---- Valid save with CSV-hostile text ---- + await page.fill("#door", "1"); + await page.fill("#biz", 'Joe\'s "Plumbing", LLC'); + await page.fill("#notes", 'said "another door guy",\nburned by SEO before'); + await page.fill("#cb", "2026-08-12"); + await page.click("#saveBtn"); + check("m: door 1 saved", (await page.locator(".msg.ok").textContent()).includes("Door 1 saved")); + check("m: progress 1/20", (await page.locator("#barLabel").textContent()).trim() === "1 / 20 doors"); + check("m: tally $497:1", (await page.locator("#cohorts").textContent()).includes("$497: 1")); + check("m: door auto-advances to 2", (await page.inputValue("#door")) === "2"); + check("m: entry shows owner + sit in meta", (await page.locator(".entry .meta").first().textContent()).includes("owner yes")); + check("m: saved door is a closed disclosure", await page.locator("details.entry").count() === 1 && !(await page.locator("details.entry").first().getAttribute("open"))); + await page.click("details.entry summary"); + check("m: saved door opens on tap", await page.locator("details.entry").first().getAttribute("open") !== null); + const savedDetails = await page.locator("details.entry .entry-details").first().innerText(); + check("m: saved door reveals business", savedDetails.includes("Joe's \"Plumbing\", LLC")); + check("m: saved door reveals callback date", savedDetails.toLowerCase().includes("callback date") && savedDetails.includes("2026-08-12")); + check("m: saved door preserves multiline objection", savedDetails.includes("said \"another door guy\"") && savedDetails.includes("burned by SEO before")); + check("m: saved door details are read-only", await page.locator("details.entry .entry-details input, details.entry .entry-details button, details.entry .entry-details textarea, details.entry .entry-details select").count() === 0); + check("m: top progress updates", (await page.locator("#topProgress").textContent()) === "1 / 20"); + + // ---- Duplicate ---- + await saveDoor(page, { door: 1, outcome: "no answer", element: "unread" }); + check("m: duplicate door 1 rejected", (await page.locator(".msg.err").textContent()).includes("already logged")); + + // ---- Shared data: RESULTS + CLIENT see the same store ---- + await nav(page, "results"); + check("m: RESULTS counts the saved door", (await page.locator("#statGrid").textContent()).includes("1")); + check("m: RESULTS gate incomplete", (await page.locator("#gateVerdict").textContent()).includes("INCOMPLETE")); + await nav(page, "client"); + check("m: CLIENT price follows next door 2 → $997", (await page.locator("#cPrice").textContent()).includes("$997")); + await page.click("#clientExit"); + await nav(page, "log"); + + // ---- Persistence + draft ---- + await page.reload(); + check("m: entry persists after reload", (await page.locator("#barLabel").textContent()).trim() === "1 / 20 doors"); + await nav(page, "log"); + await page.click("details.entry summary"); + check("m: read-only details persist after reload", (await page.locator("details.entry .entry-details").first().innerText()).includes("2026-08-12")); + await page.fill("#notes", "draft-survives-reload"); + await page.waitForTimeout(100); + await page.reload(); + await nav(page, "log"); + check("m: draft restored after reload", (await page.inputValue("#notes")) === "draft-survives-reload"); + + // ---- CSV ---- + const [dl1] = await Promise.all([page.waitForEvent("download"), page.click("#exportBtn")]); + const csv1 = fs.readFileSync(await dl1.path(), "utf8"); + check("m: csv header includes new fields in order", + csv1.split("\r\n")[0] === "door_number,timestamp,business_name,trade,price_cohort,owner_present,demo_shown,demo_result,outcome,sit_down_booked,objection_element,notes,callback_date"); + check("m: csv escapes embedded quotes", csv1.includes('"Joe\'s ""Plumbing"", LLC"')); + check("m: csv preserves newline in quoted note", csv1.includes('"said ""another door guy"",\nburned by SEO before"')); + check("m: csv row carries owner+sit values", /\$497,yes,yes,absent,soft no,no/.test(csv1)); + + // ---- Legacy migration (fallback-logger entries) ---- + await seed(page, [ + { door_number: 1, timestamp: "2026-08-10 08:00", business_name: "Legacy Shop", trade: "plumber", price_cohort: "$497", demo_shown: "yes", demo_result: "absent", outcome: "soft no", objection_element: "water", notes: "", callback_date: "" }, + { door_number: 2, timestamp: "2026-08-10 08:10", business_name: "", trade: "hvac", price_cohort: "$997", demo_shown: "no", demo_result: "not run", outcome: "no answer", objection_element: "unread", notes: "", callback_date: "" } + ]); + check("m: legacy entries still count", (await page.locator("#topProgress").textContent()) === "2 / 20"); + await nav(page, "log"); + check("m: legacy answered door shows owner —", (await page.locator(".entry .meta").allTextContents()).some(t => t.includes("owner —"))); + const [dl2] = await Promise.all([page.waitForEvent("download"), page.click("#exportBtn")]); + const csv2 = fs.readFileSync(await dl2.path(), "utf8"); + check("m: legacy no-answer exports derived N/A", /^2,.*not applicable,no,not run,no answer,no/m.test(csv2)); + check("m: legacy answered exports empty owner", /^1,[^\n]*\$497,,yes,absent,soft no,,water/m.test(csv2)); + await nav(page, "results"); + check("m: legacy incomplete flagged in gate", (await page.locator("#gateDetail").textContent()).includes("missing owner-present")); + + // ---- Denominator correctness (partial data, no verdict) ---- + await seed(page, [ + entry(1, { owner_present: "yes", demo_shown: "yes", demo_result: "present" }), + entry(2, { owner_present: "yes", demo_shown: "no", demo_result: "not run" }), + entry(3, { owner_present: "no", demo_shown: "yes", demo_result: "absent" }), + noAnswer(4) + ]); + await nav(page, "results"); + const demoCard = await page.locator("#demoRateCard").textContent(); + check("m: demo denominator = owner-present answered (2)", demoCard.includes("of 2 owner conversations")); + check("m: demo numerator = 1 → 50%", demoCard.includes("50%")); + check("m: partial run stays no-verdict", (await page.locator("#gateVerdict").textContent()).includes("no verdict")); + + // ---- Complete 20: PASS scenario ---- + const passSet = []; + for (let d = 1; d <= 20; d++) { + if (d <= 4) passSet.push(noAnswer(d)); + else if (d <= 14) passSet.push(entry(d, { + owner_present: "yes", + demo_shown: d <= 10 ? "yes" : "no", + demo_result: d <= 10 ? (d % 2 ? "present" : "absent") : "not run", + outcome: d === 5 ? "closed" : (d <= 8 ? "callback set" : "soft no"), + sit_down_booked: d >= 6 && d <= 8 ? "yes" : "no" + })); + else passSet.push(entry(d, { owner_present: "no", demo_shown: "no", demo_result: "not run" })); + } + await seed(page, passSet); + await nav(page, "results"); + check("m: complete-run gate PASS", (await page.locator("#gateVerdict").textContent()).includes("LEARN GATES MET")); + check("m: PASS detail uses owner denominator", (await page.locator("#gateDetail").textContent()).includes("of 10 owner conversations")); + check("m: cohort card labeled direction only", (await page.locator("#cohortCard").textContent()).includes("direction")); + check("m: log locks at completion", await page.locator("#saveBtn").isDisabled()); + check("m: phase advances to First close", (await page.locator("#topPhase").textContent()) === "First close"); + check("m: NEXT MOVE routes to results at 20", (await page.locator("#nextMoveBtn").textContent()).includes("RESULTS")); + await nav(page, "log"); + await page.evaluate(() => document.getElementById("f").requestSubmit()); + check("m: 21st save rejected", (await page.locator(".msg.err").textContent()).includes("Sprint complete")); + check("m: entry count stays 20", await page.evaluate(() => JSON.parse(localStorage.getItem("lehi-field-log-v1")).length) === 20); + + // ---- Complete 20: ATTENTION scenario ---- + const missSet = []; + for (let d = 1; d <= 20; d++) { + if (d <= 4) missSet.push(noAnswer(d)); + else if (d <= 14) missSet.push(entry(d, { + owner_present: "yes", + demo_shown: d <= 6 ? "yes" : "no", + demo_result: d <= 6 ? "absent" : "not run", + outcome: "soft no", sit_down_booked: d === 5 ? "yes" : "no" + })); + else missSet.push(entry(d, { owner_present: "no", demo_shown: "no", demo_result: "not run" })); + } + await seed(page, missSet); + await nav(page, "results"); + check("m: gates-not-met verdict", (await page.locator("#gateVerdict").textContent()).includes("GATES NOT MET")); + const missDetail = await page.locator("#gateDetail").textContent(); + check("m: miss detail names demo gate", missDetail.includes("<50%")); + check("m: miss detail names offer gate", missDetail.includes("sit-downs")); + + // ---- Reset (typed gate) from RESULTS-shared store via LOG ---- + await nav(page, "log"); + await page.click(".reset-box summary"); + check("m: reset disabled before typing", await page.locator("#resetBtn").isDisabled()); + await page.fill("#resetConfirm", "reset"); + check("m: reset disabled wrong case", await page.locator("#resetBtn").isDisabled()); + await page.fill("#resetConfirm", "RESET"); + check("m: reset armed by RESET", !(await page.locator("#resetBtn").isDisabled())); + await page.click("#resetBtn"); + check("m: reset clears to 0/20", (await page.locator("#barLabel").textContent()).trim() === "0 / 20 doors"); + check("m: reset re-enables save", !(await page.locator("#saveBtn").isDisabled())); + await page.reload(); + check("m: reset persists", (await page.locator("#topProgress").textContent()) === "0 / 20"); + + // ---- Overflow + network (mobile) ---- + for (const v of ["command","workflow","log","results"]) { + await nav(page, v); + const w = await page.evaluate(() => document.documentElement.scrollWidth); + check("m: no horizontal overflow on " + v, w <= 390, "scrollWidth=" + w); + } + await nav(page, "client"); + const wc = await page.evaluate(() => document.documentElement.scrollWidth); + check("m: no horizontal overflow on client", wc <= 390, "scrollWidth=" + wc); + check("m: zero external requests (mobile)", external.length === 0, external.join(", ") || "none"); + await ctx.close(); + + /* ================= DESKTOP (1280px) ================= */ + const dctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const dpage = await dctx.newPage(); + const dexternal = []; + dpage.on("request", (r) => { if (!r.url().startsWith("file://")) dexternal.push(r.url()); }); + await dpage.goto(FILE); + check("d: loads at desktop", (await dpage.title()) === "Guild Command Center"); + check("d: rail is vertical at desktop", await dpage.locator("#tabbar").evaluate(t => getComputedStyle(t).flexDirection === "column")); + for (const v of ["workflow","log","results","command"]) { + await dpage.click(`#tabbar button[data-view="${v}"]`); + check("d: nav to " + v, await dpage.locator("#view-" + v).evaluate(el => el.classList.contains("on"))); + } + const dw = await dpage.evaluate(() => document.documentElement.scrollWidth); + check("d: no horizontal overflow", dw <= 1280, "scrollWidth=" + dw); + check("d: zero external requests (desktop)", dexternal.length === 0, dexternal.join(", ") || "none"); + await dctx.close(); + + await browser.close(); + console.log(results.join("\n")); + console.log(results.filter(r => r.startsWith("FAIL")).length + " failures / " + results.length + " checks"); +})().catch(e => { console.error("SMOKE CRASH:", e); process.exit(1); }); diff --git a/pwa/README.md b/pwa/README.md new file mode 100644 index 0000000..491fd13 --- /dev/null +++ b/pwa/README.md @@ -0,0 +1,48 @@ +# Guild Command Center PWA + +The canonical application source is `playbooks/guild-command-center.html`. +The PWA shell in this directory adds installability, offline caching, icons, +security headers, guarded CSV restore, and update handling without adding a +backend, login, CRM, API call, or paid service. + +## Live release + +- **URL:** https://guild-command-center.brandonwadepackard.workers.dev +- **Status:** RELEASE_READY for the Lehi 20-door field sprint — v1.0.1 + automated + live checks passed at source commit `a46c2ba`; actual-iPhone + install, save, saved-row open, export, airplane-mode reopen, and typed reset + are complete. +- **Receipt:** `pwa/RELEASE-2026-08-09.md` (etag, byte hashes, live checks). +- Transport is a static Cloudflare Worker with no bindings, storage, + analytics, API, payment, CRM, or OpenAI dependency. The URL is + public-by-URL; all field data stays device-local. + +## Build and test + +```bash +npm ci +npm run build +npm test +``` + +`npm run build` stages only the public app files in ignored `dist/`. It copies +the canonical HTML byte-for-byte to `dist/index.html`, so the artifact and the +deployed app cannot silently become two different products. + +## Data contract + +- Door data stays in this browser/device's local storage. +- Export CSV at the end of every field session and before reset or restore. +- CSV restore validates the exact schema, unique doors 1–20, locked odd/even + price cohorts, and demo/no-answer truth rules before showing a destructive + confirmation. It never merges ambiguous records. +- The service worker caches the app shell, not the field log. Browser storage + remains the canonical working copy; the exported CSV is the portable backup. + +## Release proof + +Desktop automation proves build identity, manifest/install assets, security +configuration, guarded restore, persistence across reopen, and offline reopen +after first load. A release remains limited-test ready until Brandon proves on +his actual phone: Add to Home Screen, save/export, close/reopen, airplane-mode +reopen, restore, then typed reset. diff --git a/pwa/RELEASE-2026-08-09.md b/pwa/RELEASE-2026-08-09.md new file mode 100644 index 0000000..bb947aa --- /dev/null +++ b/pwa/RELEASE-2026-08-09.md @@ -0,0 +1,69 @@ +# Guild Command Center — Release Receipt — 2026-08-09 + +Verdict: **RELEASE_READY** for the Lehi 20-door field sprint. Brandon completed +the actual-iPhone install, save, read-only detail open, CSV export, app close, +airplane-mode reopen with data intact, and typed-reset cleanup. Deployment was +verified by Codex (Mac lane); device steps were performed and reported by +Brandon, with the opened saved-record state captured in the session. + +## Release identity + +| Item | Value | +|---|---| +| Live URL | https://guild-command-center.brandonwadepackard.workers.dev | +| App source commit | `a46c2ba8145e0414c3263c8ad2e1b448ecc3fe17` | +| Saved-log feature commit | `30820cf8774b2645264f4642f3cd07913ea28680` | +| Cloudflare Worker upload etag | `e052dbf494c33e2e7bd862ecd5ad00a16456153afc78bba077bd6acb5fa81b7c` | +| Live `X-Guild-Commit` header | `a46c2ba…` | + +## Byte hashes of the live assets (SHA-256) + +| Asset | Hash | +|---|---| +| HTML (`/index.html`) | `4fb41f047739923855023eba3c7260f0d00f33ded2332e93868a7de0a353f8ae` | +| `manifest.webmanifest` | `dbf2432f33834797c5fa2645dc154b3ca123882df7fd48c4a5c1750983d6aa2a` | +| `sw.js` | `59feb26d312c47d6439718c5706640833f35c2f1f250db337a7857d82790f9af` | + +## Automated proof (at source commit `a46c2ba`) + +- Command Center smoke: **137/137** · PWA smoke: **27/27** · total **164/164** +- The PWA suite passed three consecutive offline runs after its asynchronous + offline-status assertion was stabilized. +- `npm audit --omit=dev`: 0 vulnerabilities · secret-shape scan: clean + +## Live checks (verified against the deployed URL) + +- HTTPS 200 · service worker takes control · saved state survives + close/reopen and offline reload · zero external requests +- A saved row starts closed, opens to all recorded fields, exposes no mutation + controls, and still opens after close/reopen and while offline. +- Unknown routes 404 · POST 405 +- Headers: CSP self-only · `X-Frame-Options: DENY` · no-cache release + headers · `X-Guild-Commit: a46c2ba…` + +## Transport and data posture + +Static Cloudflare Worker with **no bindings, storage, analytics, API, +payment, CRM, or OpenAI dependency**. The URL is public-by-URL; it serves +only the app shell. All field data remains device-local (browser +localStorage); the exported CSV is the portable backup. + +## Source of truth + +The canonical application source remains +`playbooks/guild-command-center.html`. `dist/` stays generated and +gitignored — the build stages a byte-identical copy, so the artifact, +repo, and deployed app cannot silently diverge. + +## Real-device proof completed + +- Installed from Safari to the actual iPhone Home Screen. +- Saved PHONE PROOF and confirmed the counter at 1/20. +- Opened the saved row after the v1.0.1 update; the captured screen shows all + recorded fields and the read-only disclosure state. +- Exported the CSV to Gmail as the portable backup. +- Closed the app, enabled airplane mode, reopened the installed app, and + confirmed the saved record and details still worked. +- Used the typed RESET gate after export and confirmed cleanup worked. + +No blocking release gate remains for the approved Lehi field-sprint scope. diff --git a/pwa/_headers b/pwa/_headers new file mode 100644 index 0000000..d4a61c6 --- /dev/null +++ b/pwa/_headers @@ -0,0 +1,24 @@ +/* + X-Content-Type-Options: nosniff + Referrer-Policy: no-referrer + X-Frame-Options: DENY + Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() + Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' + +/ + Cache-Control: no-cache, no-store, must-revalidate + +/index.html + Cache-Control: no-cache, no-store, must-revalidate + +/sw.js + Cache-Control: no-cache, no-store, must-revalidate + Service-Worker-Allowed: / + +/manifest.webmanifest + Cache-Control: no-cache + Content-Type: application/manifest+json + +/icons/* + Cache-Control: public, max-age=31536000, immutable + diff --git a/pwa/icons/icon-192.png b/pwa/icons/icon-192.png new file mode 100644 index 0000000..36bee9c Binary files /dev/null and b/pwa/icons/icon-192.png differ diff --git a/pwa/icons/icon-512.png b/pwa/icons/icon-512.png new file mode 100644 index 0000000..44ea883 Binary files /dev/null and b/pwa/icons/icon-512.png differ diff --git a/pwa/icons/icon-maskable-512.png b/pwa/icons/icon-maskable-512.png new file mode 100644 index 0000000..44ea883 Binary files /dev/null and b/pwa/icons/icon-maskable-512.png differ diff --git a/pwa/icons/icon-source.svg b/pwa/icons/icon-source.svg new file mode 100644 index 0000000..dce6718 --- /dev/null +++ b/pwa/icons/icon-source.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/pwa/manifest.webmanifest b/pwa/manifest.webmanifest new file mode 100644 index 0000000..c539432 --- /dev/null +++ b/pwa/manifest.webmanifest @@ -0,0 +1,34 @@ +{ + "id": "/", + "name": "Guild Command Center", + "short_name": "Guild", + "description": "Offline field command center for The Guild's 20-door Lehi learn sprint.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#F7F8F6", + "theme_color": "#16334E", + "categories": ["business", "productivity"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} + diff --git a/pwa/sw.js b/pwa/sw.js new file mode 100644 index 0000000..6638dc9 --- /dev/null +++ b/pwa/sw.js @@ -0,0 +1,59 @@ +/* Guild Command Center offline shell. Bump CACHE_VERSION for every release. */ +const CACHE_VERSION = "guild-command-center-v1.0.1"; +const APP_SHELL = [ + "/", + "/index.html", + "/manifest.webmanifest", + "/icons/icon-192.png", + "/icons/icon-512.png", + "/icons/icon-maskable-512.png" +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE_VERSION).then((cache) => cache.addAll(APP_SHELL))); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting(); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_VERSION).then((cache) => cache.put("/index.html", copy)); + return response; + }) + .catch(() => caches.match("/index.html")) + ); + return; + } + + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request).then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(CACHE_VERSION).then((cache) => cache.put(request, copy)); + } + return response; + }); + }) + ); +}); diff --git a/pwa/tests/pwa-smoke.js b/pwa/tests/pwa-smoke.js new file mode 100644 index 0000000..ab43d68 --- /dev/null +++ b/pwa/tests/pwa-smoke.js @@ -0,0 +1,122 @@ +/* End-to-end PWA, offline, persistence, and guarded-restore proof. */ +const { chromium } = require("playwright"); +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, "..", ".."); +const DIST = path.join(ROOT, "dist"); +const results = []; +function check(name, ok, detail) { + results.push((ok ? "PASS" : "FAIL") + " " + name + (detail ? " (" + detail + ")" : "")); + if (!ok) process.exitCode = 1; +} +function contentType(file) { + if (file.endsWith(".html")) return "text/html; charset=utf-8"; + if (file.endsWith(".js")) return "text/javascript; charset=utf-8"; + if (file.endsWith(".webmanifest")) return "application/manifest+json"; + if (file.endsWith(".png")) return "image/png"; + return "application/octet-stream"; +} +function launchOptions() { + const candidates = [ + "/opt/pw-browsers/chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + ]; + const executablePath = candidates.find((candidate) => fs.existsSync(candidate)); + return executablePath ? { executablePath } : {}; +} + +(async () => { + execFileSync("bash", [path.join(ROOT, "scripts", "build-guild-pwa.sh")], { cwd: ROOT, stdio: "pipe" }); + check("build output is byte-identical to canonical HTML", fs.readFileSync(path.join(DIST, "index.html")).equals(fs.readFileSync(path.join(ROOT, "playbooks", "guild-command-center.html")))); + + const manifest = JSON.parse(fs.readFileSync(path.join(DIST, "manifest.webmanifest"), "utf8")); + check("manifest names the Guild Command Center", manifest.name === "Guild Command Center" && manifest.short_name === "Guild"); + check("manifest launches standalone at root", manifest.display === "standalone" && manifest.start_url === "/" && manifest.scope === "/"); + check("manifest has 192, 512, and maskable icons", manifest.icons.some((i) => i.sizes === "192x192") && manifest.icons.some((i) => i.sizes === "512x512" && i.purpose === "any") && manifest.icons.some((i) => i.purpose === "maskable")); + for (const icon of manifest.icons) check("manifest icon exists: " + icon.src, fs.existsSync(path.join(DIST, icon.src.replace(/^\//, "")))); + const headers = fs.readFileSync(path.join(DIST, "_headers"), "utf8"); + check("deployment headers deny framing and external connections", headers.includes("X-Frame-Options: DENY") && headers.includes("connect-src 'self'") && headers.includes("frame-ancestors 'none'")); + check("service worker and HTML use no-cache release headers", headers.includes("/sw.js") && headers.includes("no-cache, no-store, must-revalidate")); + + const server = http.createServer((req, res) => { + const rawPath = new URL(req.url, "http://localhost").pathname; + const relative = rawPath === "/" ? "index.html" : decodeURIComponent(rawPath.replace(/^\/+/, "")); + const file = path.resolve(DIST, relative); + if (!file.startsWith(DIST + path.sep) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { + res.writeHead(404); res.end("not found"); return; + } + res.writeHead(200, { "Content-Type": contentType(file), "Cache-Control": "no-cache" }); + fs.createReadStream(file).pipe(res); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const browser = await chromium.launch(launchOptions()); + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); + const external = []; + page.on("request", (request) => { if (!request.url().startsWith(origin)) external.push(request.url()); }); + await page.goto(origin, { waitUntil: "networkidle" }); + check("live app links its manifest", await page.locator('link[rel="manifest"]').getAttribute("href") === "manifest.webmanifest"); + check("live app exposes install guidance", await page.locator("#installBtn").count() === 1 && (await page.locator("#installHelp").textContent()).includes("Add to Home Screen")); + await page.waitForFunction(() => navigator.serviceWorker && navigator.serviceWorker.controller, null, { timeout: 10000 }); + check("service worker controls the live app", await page.evaluate(() => !!navigator.serviceWorker.controller)); + check("offline-ready state is visible", await page.locator("#installDot").evaluate((el) => el.classList.contains("ready"))); + + const validCSV = [ + "door_number,timestamp,business_name,trade,price_cohort,owner_present,demo_shown,demo_result,outcome,sit_down_booked,objection_element,notes,callback_date", + '1,2026-08-09 10:00,"",plumber,$497,yes,yes,absent,callback set,yes,water,"line one,\nline two",2026-08-12', + "2,2026-08-09 10:10,Second Shop,hvac,$997,not applicable,no,not run,no answer,no,unread,," + ].join("\r\n") + "\r\n"; + await page.click('#tabbar button[data-view="log"]'); + await page.setInputFiles("#importFile", { name: "backup.csv", mimeType: "text/csv", buffer: Buffer.from(validCSV) }); + await page.waitForFunction(() => document.getElementById("restorePanel").classList.contains("show")); + check("valid restore is previewed before mutation", (await page.locator("#restoreSummary").textContent()).includes("2 validated doors") && (await page.locator("#topProgress").textContent()) === "0 / 20"); + await page.click("#restoreConfirm"); + check("confirmed restore replaces the working log", (await page.locator("#topProgress").textContent()) === "2 / 20"); + check("restored doors expose read-only disclosures", await page.locator("details.entry").count() === 2); + await page.locator("details.entry summary").first().click(); + const restoredDetails = await page.locator("details.entry .entry-details").first().innerText(); + check("restored disclosure shows complete saved data", restoredDetails.includes("Second Shop") && restoredDetails.includes("no answer") && restoredDetails.toLowerCase().includes("demo result")); + check("restored disclosure has no mutation controls", await page.locator("details.entry .entry-details input, details.entry .entry-details button, details.entry .entry-details textarea, details.entry .entry-details select").count() === 0); + await page.click('#tabbar button[data-view="results"]'); + check("restored business text cannot inject HTML", await page.locator("#callbackCard img").count() === 0 && await page.evaluate(() => !window.pwned)); + + await page.click('#tabbar button[data-view="log"]'); + const duplicateCSV = validCSV.replace("2,2026-08-09", "1,2026-08-09"); + await page.setInputFiles("#importFile", { name: "duplicate.csv", mimeType: "text/csv", buffer: Buffer.from(duplicateCSV) }); + await page.waitForFunction(() => document.getElementById("msg").textContent.includes("Restore blocked")); + check("duplicate restore is rejected without changing data", (await page.locator("#msg").textContent()).includes("appears more than once") && (await page.locator("#topProgress").textContent()) === "2 / 20"); + const wrongCohortCSV = validCSV.replace("Second Shop,hvac,$997", "Second Shop,hvac,$497"); + await page.setInputFiles("#importFile", { name: "wrong-cohort.csv", mimeType: "text/csv", buffer: Buffer.from(wrongCohortCSV) }); + await page.waitForFunction(() => document.getElementById("msg").textContent.includes("wrong locked price cohort")); + check("wrong locked cohort is rejected", (await page.locator("#topProgress").textContent()) === "2 / 20"); + + await page.close(); + const reopened = await context.newPage(); + await reopened.goto(origin, { waitUntil: "networkidle" }); + check("data persists after app close and reopen", (await reopened.locator("#topProgress").textContent()) === "2 / 20"); + await reopened.click('#tabbar button[data-view="log"]'); + await reopened.locator("details.entry summary").first().click(); + check("saved details survive app close and reopen", (await reopened.locator("details.entry .entry-details").first().innerText()).includes("Second Shop")); + await context.setOffline(true); + await reopened.reload({ waitUntil: "domcontentloaded" }); + check("app reopens offline with data intact", (await reopened.title()) === "Guild Command Center" && (await reopened.locator("#topProgress").textContent()) === "2 / 20"); + await reopened.click('#tabbar button[data-view="log"]'); + await reopened.locator("details.entry summary").first().click(); + check("saved details open while offline", (await reopened.locator("details.entry .entry-details").first().innerText()).includes("Second Shop")); + await reopened.waitForFunction(() => document.getElementById("installStatus").textContent.includes("Offline"), null, { timeout: 5000 }); + check("offline state is visible", (await reopened.locator("#installStatus").textContent()).includes("Offline")); + check("live app made zero external requests", external.length === 0, external.join(", ") || "none"); + + await context.close(); + await browser.close(); + await new Promise((resolve) => server.close(resolve)); + console.log(results.join("\n")); + console.log(results.filter((r) => r.startsWith("FAIL")).length + " failures / " + results.length + " checks"); +})().catch((error) => { + console.error("PWA SMOKE CRASH:", error); + process.exit(1); +}); diff --git a/scripts/build-guild-pwa.sh b/scripts/build-guild-pwa.sh new file mode 100644 index 0000000..2a36529 --- /dev/null +++ b/scripts/build-guild-pwa.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +dist_dir="$repo_root/dist" + +mkdir -p "$dist_dir/icons" +cp "$repo_root/playbooks/guild-command-center.html" "$dist_dir/index.html" +cp "$repo_root/pwa/manifest.webmanifest" "$dist_dir/manifest.webmanifest" +cp "$repo_root/pwa/sw.js" "$dist_dir/sw.js" +cp "$repo_root/pwa/_headers" "$dist_dir/_headers" +cp "$repo_root/pwa/icons/icon-192.png" "$dist_dir/icons/icon-192.png" +cp "$repo_root/pwa/icons/icon-512.png" "$dist_dir/icons/icon-512.png" +cp "$repo_root/pwa/icons/icon-maskable-512.png" "$dist_dir/icons/icon-maskable-512.png" + +cmp -s "$repo_root/playbooks/guild-command-center.html" "$dist_dir/index.html" +echo "Built Guild Command Center PWA in $dist_dir" +