@aiguru/google-web-operations 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.env.example +42 -0
  2. package/README.md +110 -0
  3. package/apps/cli/src/index.js +286 -0
  4. package/apps/mcp-server/src/index.js +360 -0
  5. package/apps/worker/src/index.js +96 -0
  6. package/apps/worker/src/loop.js +46 -0
  7. package/bin/gwo.js +67 -0
  8. package/docs/BOOTSTRAP-NEXT-PROMPT.md +57 -0
  9. package/docs/GETTING-STARTED.he.md +63 -0
  10. package/docs/HOSTED-WORKER.md +36 -0
  11. package/docs/INSTALL.he.md +57 -0
  12. package/docs/PRODUCT-TECHNICAL-SPEC-v0.2.md +153 -0
  13. package/docs/PRODUCT-TECHNICAL-SPEC-v0.3.md +86 -0
  14. package/docs/PRODUCT-TECHNICAL-SPEC-v0.4.md +92 -0
  15. package/docs/PRODUCT-TECHNICAL-SPEC-v0.5.md +94 -0
  16. package/docs/PRODUCT-TECHNICAL-SPEC-v0.6.md +98 -0
  17. package/docs/SECURITY.md +69 -0
  18. package/docs/SITE-ADAPTERS.md +87 -0
  19. package/docs/adr/ADR-001-shared-mcp-core.md +27 -0
  20. package/docs/adr/ADR-002-policy-gated-writes.md +18 -0
  21. package/docs/adr/ADR-003-single-source-playbooks.md +18 -0
  22. package/docs/adr/ADR-004-native-typescript-runtime.md +16 -0
  23. package/docs/adr/ADR-005-two-token-publish.md +22 -0
  24. package/docs/adr/ADR-006-one-package-one-installer.md +20 -0
  25. package/hosts/claude/.claude-plugin/plugin.json +9 -0
  26. package/hosts/claude/.mcp.json +10 -0
  27. package/hosts/claude/README.md +7 -0
  28. package/hosts/claude/commands/gwo-add-site.md +7 -0
  29. package/hosts/claude/commands/gwo-daily.md +7 -0
  30. package/hosts/claude/commands/gwo-measure.md +7 -0
  31. package/hosts/claude/commands/gwo-monthly.md +7 -0
  32. package/hosts/claude/commands/gwo-onboard.md +7 -0
  33. package/hosts/claude/commands/gwo-scan.md +7 -0
  34. package/hosts/claude/commands/gwo-setup.md +7 -0
  35. package/hosts/claude/commands/gwo-triage.md +7 -0
  36. package/hosts/claude/commands/gwo-weekly.md +7 -0
  37. package/hosts/claude/skills/gwo-client-report-template/SKILL.md +36 -0
  38. package/hosts/claude/skills/gwo-content-brief/SKILL.md +60 -0
  39. package/hosts/claude/skills/gwo-daily/SKILL.md +47 -0
  40. package/hosts/claude/skills/gwo-measurement/SKILL.md +92 -0
  41. package/hosts/claude/skills/gwo-monthly/SKILL.md +30 -0
  42. package/hosts/claude/skills/gwo-onboarding/SKILL.md +27 -0
  43. package/hosts/claude/skills/gwo-operating-model/SKILL.md +76 -0
  44. package/hosts/claude/skills/gwo-setup/SKILL.md +42 -0
  45. package/hosts/claude/skills/gwo-triage/SKILL.md +37 -0
  46. package/hosts/claude/skills/gwo-weekly/SKILL.md +48 -0
  47. package/hosts/codex/.codex/config.template.toml +5 -0
  48. package/hosts/codex/.codex-plugin/plugin.json +30 -0
  49. package/hosts/codex/AGENTS.md +470 -0
  50. package/hosts/codex/README.md +13 -0
  51. package/hosts/codex/skills/gwo-client-report-template/SKILL.md +36 -0
  52. package/hosts/codex/skills/gwo-content-brief/SKILL.md +60 -0
  53. package/hosts/codex/skills/gwo-daily/SKILL.md +47 -0
  54. package/hosts/codex/skills/gwo-measurement/SKILL.md +92 -0
  55. package/hosts/codex/skills/gwo-monthly/SKILL.md +30 -0
  56. package/hosts/codex/skills/gwo-onboarding/SKILL.md +27 -0
  57. package/hosts/codex/skills/gwo-operating-model/SKILL.md +76 -0
  58. package/hosts/codex/skills/gwo-setup/SKILL.md +42 -0
  59. package/hosts/codex/skills/gwo-triage/SKILL.md +37 -0
  60. package/hosts/codex/skills/gwo-weekly/SKILL.md +48 -0
  61. package/package.json +24 -0
  62. package/packages/connectors/browser/src/index.js +122 -0
  63. package/packages/connectors/docx/src/index.js +122 -0
  64. package/packages/connectors/github/src/index.js +102 -0
  65. package/packages/connectors/google/src/auth.js +73 -0
  66. package/packages/connectors/google/src/discover.js +135 -0
  67. package/packages/connectors/google/src/ga4.js +42 -0
  68. package/packages/connectors/google/src/gsc.js +49 -0
  69. package/packages/connectors/google/src/gtm.js +261 -0
  70. package/packages/connectors/google/src/index.js +8 -0
  71. package/packages/connectors/google/src/oauth.js +198 -0
  72. package/packages/connectors/google/src/pagespeed.js +48 -0
  73. package/packages/connectors/google/src/token-store.js +89 -0
  74. package/packages/connectors/odoo/src/index.js +206 -0
  75. package/packages/connectors/repo/src/index.js +94 -0
  76. package/packages/connectors/resend/src/index.js +30 -0
  77. package/packages/connectors/web-crawler/src/a11y.js +91 -0
  78. package/packages/connectors/web-crawler/src/fetch.js +165 -0
  79. package/packages/connectors/web-crawler/src/index.js +69 -0
  80. package/packages/connectors/web-crawler/src/parse.js +157 -0
  81. package/packages/core/src/adapters.js +27 -0
  82. package/packages/core/src/admin.js +232 -0
  83. package/packages/core/src/content.js +213 -0
  84. package/packages/core/src/doctor.js +39 -0
  85. package/packages/core/src/escalate.js +36 -0
  86. package/packages/core/src/gtm-ops.js +311 -0
  87. package/packages/core/src/index.js +13 -0
  88. package/packages/core/src/operations.js +239 -0
  89. package/packages/core/src/paths.js +46 -0
  90. package/packages/core/src/registry.js +114 -0
  91. package/packages/core/src/report/monthly.js +272 -0
  92. package/packages/core/src/scan/render.js +40 -0
  93. package/packages/core/src/scan/rules.js +289 -0
  94. package/packages/core/src/scan/suppress.js +37 -0
  95. package/packages/core/src/types.js +10 -0
  96. package/packages/core/src/writes.js +50 -0
  97. package/packages/playbooks/00-operating-model.md +74 -0
  98. package/packages/playbooks/10-daily.md +45 -0
  99. package/packages/playbooks/20-weekly.md +46 -0
  100. package/packages/playbooks/30-monthly.md +28 -0
  101. package/packages/playbooks/40-triage.md +35 -0
  102. package/packages/playbooks/50-onboarding.md +25 -0
  103. package/packages/playbooks/60-content-brief.md +58 -0
  104. package/packages/playbooks/70-measurement.md +90 -0
  105. package/packages/playbooks/80-setup.md +40 -0
  106. package/packages/playbooks/90-client-report-template.md +34 -0
  107. package/packages/policy/src/index.js +115 -0
  108. package/packages/storage/src/index.js +131 -0
  109. package/policies/default.yaml +90 -0
  110. package/sites/schema.yaml +71 -0
  111. package/sites/secrets.example.yaml +6 -0
  112. package/tools/build-hosts.js +118 -0
  113. package/tools/build-package.js +43 -0
  114. package/tools/install.js +145 -0
  115. package/tools/schedule.ps1 +37 -0
  116. package/tools/smoke-mcp.js +61 -0
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: gwo-onboarding
3
+ description: Bring a new site into operations. Registers it, captures a baseline scan, records the access the client granted, and produces the starting-point report.
4
+ ---
5
+
6
+ # Onboarding a site
7
+
8
+ Goal: a registry entry, a baseline, and a clear picture of what the client granted and what is still missing.
9
+
10
+ ## Steps
11
+
12
+ 1. **Register.** `ops_add_site` with the URL, name and platform (and for Odoo the ERP url and database). It writes `sites/<host>.yaml` with defaults; Google ids stay null until access is granted, then `google_discover_apply` fills them (gwo-setup).
13
+ 2. **Baseline.** `web_scan_site` at `maxPages` 200. This is the "before" picture. Note the severity counts in the onboarding record.
14
+ 3. **Access checklist.** The client adds the company operations Google account to:
15
+ - Search Console: Full permission on the domain property.
16
+ - Analytics: Editor on the GA4 property (Administrator if configuration changes are in scope). Record the numeric property id in the site file.
17
+ - Tag Manager: Publish permission on the container, if one exists.
18
+ Record what was granted and on what date (`ops_update_site` on `notes`, or the site's contacts). The operations account address comes from `ops_setup_status`.
19
+ 4. **Write path.** Confirm how the site can be changed: Odoo credentials in `sites/<host>.secrets.yaml`, or a repository path and branch. Test one read through the adapter before declaring the site `full` tier.
20
+ 5. **Measurement.** Run the audit in gwo-measurement. If the baseline shows no analytics, that is the first task: the client creates the container and grants Publish, then you stage the standard stack. If a container exists, `gtm_workspaces` shows whether the client has unpublished work; ask before staging next to it. Nothing else works without measurement.
21
+ 6. **Starting-point report.** From the gwo-client-report-template: where the site stands, the first three things that will be fixed, what is needed from the client.
22
+
23
+ ## Done when
24
+
25
+ The site file is complete, a baseline snapshot exists, the access checklist has dates, and the operator has seen the starting-point report.
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: gwo-content-brief
3
+ description: How to write titles, meta descriptions and structured-data proposals for client sites, in Hebrew and English, so that search engines and answer engines both pick them up. Load before any content write.
4
+ ---
5
+
6
+ # Content brief
7
+
8
+ You write the text. The tools validate and apply it. Follow this brief exactly; the validator enforces the measurable parts and the operator reviews the rest.
9
+
10
+ ## Meta description
11
+
12
+ **Length by script.** Hebrew renders denser than Latin text, so the same snippet width holds fewer characters.
13
+
14
+ | Script | Characters |
15
+ |---|---|
16
+ | Hebrew | 120 to 150 |
17
+ | Latin (English) | 70 to 160 |
18
+
19
+ **Structure.** Answer first, then the reason to click. The first clause states what the page delivers. The last clause invites the action or names who it is for. One idea per sentence. No line breaks, no HTML, no quotation marks around the whole text.
20
+
21
+ **Terms.** The page's primary term appears once, early. Never repeat a term more than twice; repetition lowers visibility with answer engines and reads as spam to people.
22
+
23
+ **Hebrew rules.**
24
+ - Ktiv maleh throughout: תוכנה, שירות, אינטרנט.
25
+ - Gender-neutral phrasing: "ניתן", "אפשר", "יש ל" instead of "אתה יכול", "המשתמש צריך".
26
+ - Business register, direct. No English-style softeners ("נשמח אם", "ייתכן שכדאי"). Say the thing.
27
+ - Established tech terms stay in English or in their spoken transliteration: API, SaaS, אימייל, סטארטאפ. Brand names stay as written.
28
+ - No calques: "בשורה התחתונה" not "בסוף היום"; "כדי" not "בכדי".
29
+ - Digits for numbers in body copy: "תוך 24 שעות".
30
+
31
+ **English rules.** Sentence case. Primary term in the first 60 characters. No exclamation marks. Numbers as digits.
32
+
33
+ **Do not** write a description that could belong to any page. If it does not name what this page uniquely offers, rewrite it.
34
+
35
+ ## Title
36
+
37
+ 50 to 60 characters, primary term near the beginning, brand at the end after one separator (`|`). Hebrew titles count Hebrew characters the same way. The validator in `content_set_title` rejects under 15 or over 65 characters, more than one separator, a repeated term, HTML and line breaks. Do not change a title on a page that already ranks without a Search Console reason; it is the most expensive edit in this playbook, and it always needs the token for `content.title.update`.
38
+
39
+ ## Structured data
40
+
41
+ Answer engines cite sources; they do not rank pages. Schema tells them what a page is.
42
+
43
+ - Sitewide: `Organization` (or `Person` for a personal brand) with name, url, logo, sameAs.
44
+ - Per template: `WebPage`, `Article` (with author and dates), `Service`, `Product`, `LocalBusiness` where true.
45
+ - `FAQPage` wherever the page already answers concrete questions. Each answer 2 to 3 sentences, answer first, a number or a source where one exists. Never invent questions the page does not answer.
46
+ - Check `site_page_schema` first: the theme may already emit `WebSite` or `Organization`. Never add a type the page already has; two `Organization` blocks with different names is worse than none.
47
+ - Write the exact JSON-LD blocks and apply them with `content_set_schema`. The validator requires `"@context": "https://schema.org"` and an `@type` on every block, a non-empty `mainEntity` on `FAQPage`, no `</script` inside values, and at most 20 KB per page. Structured data is approval-required on every site: propose the blocks and the token for `content.schema.update` together.
48
+ - On Odoo the blocks go into the page's view; on repository sites into the manifest entry's `schema` array, which the layout renders. Either way the live page shows them only after the next render or deploy: verify with `web_inspect_url` and say "stored, awaiting deploy" until it does.
49
+
50
+ ## Content that gets cited
51
+
52
+ When proposing page-level content changes (weekly and monthly runs), prefer, in this order: a specific statistic with its source; a short expert quote with attribution; a direct answer in the first paragraph; headed sections an engine can lift; a comparison table. Avoid keyword stuffing, which measurably hurts. Fluency plus statistics is the strongest combination.
53
+
54
+ ## Before you submit any text
55
+
56
+ 1. Run it through `content validate` (CLI) or rely on the tool's validation.
57
+ 2. Read it aloud once in the page's language. If it sounds translated, it is.
58
+ 3. Check the page still has exactly one H1 and that the description does not contradict it.
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: gwo-measurement
3
+ description: Measurement operations on Tag Manager and GA4. The standard stack per client, how to audit it, and the stage-diff-publish protocol for every container change. Load before touching Tag Manager or key events.
4
+ ---
5
+
6
+ # Measurement
7
+
8
+ Goal: every client site reports the same few things, reliably, without you ever editing a container by hand or publishing something nobody reviewed.
9
+
10
+ ## The standard stack
11
+
12
+ One Tag Manager container per site, one GA4 property, and inside the container:
13
+
14
+ | Entity | Name | Type | Fires on |
15
+ |---|---|---|---|
16
+ | Google tag | `Google tag - GA4` | `googtag` | All Pages |
17
+ | Event tag | `GA4 - Event - generate_lead` | `gaawe` | the lead trigger |
18
+ | Event tag | `GA4 - Event - <name>` | `gaawe` | one trigger per event |
19
+ | Trigger | `Form - submit success` | `formSubmission` or `customEvent` | the site's form |
20
+ | Trigger | `Click - phone` | `linkClick` filtered on `tel:` | phone links |
21
+ | Trigger | `Click - WhatsApp` | `linkClick` filtered on `wa.me` | WhatsApp links |
22
+ | Variable | `GA4 measurement id` | `c` (constant) | holds `G-...` |
23
+
24
+ Names are prefixed by product and kind so a container full of a client's own tags still reads at a glance. Events use GA4's recommended names: `generate_lead`, `contact`, `sign_up`, `purchase`. The site's `conversions.primary_event` names the one that becomes a key event.
25
+
26
+ Consent: Israeli sites do not need a consent banner for analytics today, but a client selling into the EU does. If the site has a consent tool, the Google tag must fire on `Consent Initialization - All Pages` with consent defaults; do not stage analytics tags that bypass it.
27
+
28
+ ## Audit (weekly step, onboarding step 5)
29
+
30
+ 1. `web_inspect_url` on the homepage: is exactly one `GTM-` container on the page, and is the `G-` id inside the container rather than hardcoded next to it? Two Google tags double-count.
31
+ 2. `gtm_snapshot`: tags, what fires them, which are paused. A paused tag that carries a key event is an outage.
32
+ 3. `gtm_workspaces`: is there a workspace with changes nobody published? Ask the client before touching it; it may be work in progress.
33
+ 4. `ga4_key_events` against `conversions.primary_event`. Missing means attribution is off for every report.
34
+ 5. `ga4_realtime` while you submit the site's own test form (or ask the operator to): the event must arrive within a minute with the expected name.
35
+
36
+ ## Change protocol
37
+
38
+ Every container change, no exceptions:
39
+
40
+ 1. **Stage.** `gtm_stage` with the entities. Nothing is live yet. The tool creates or reuses the `gwo-` workspace, writes each entity as its own verified write, and returns the diff against the live version plus a **review token**.
41
+ 2. **Show the diff.** Present `gtm_workspace_diff` output to the operator: added, updated (with the fields), deleted. This is the moment to catch a wrong trigger.
42
+ 3. **Publish only with both tokens.** `gtm_publish` needs the approval token for `gtm.publish` and the review token. The review token is computed from the exact set of staged changes and the live version id; if anything moved since the operator looked, it will not match, and the publish is refused with a message saying to diff again.
43
+ 4. **Verify.** After publish: `gtm_versions` shows the new version, `web_inspect_url` still shows the container, `ga4_realtime` shows the event on a test action.
44
+
45
+ What you never do: create tags outside the `gwo-` workspace, publish a workspace whose diff you have not presented, delete a tag (pause it instead; deletion is protected), or touch a workspace the client owns.
46
+
47
+ ## Entity bodies
48
+
49
+ `gtm_stage` takes GTM API resources without ids. Trigger names may be used in `firingTriggerId`; the tool resolves them.
50
+
51
+ Google tag:
52
+ ```json
53
+ { "kind": "tag", "body": { "name": "Google tag - GA4", "type": "googtag", "firingTriggerId": ["All Pages"],
54
+ "parameter": [{ "type": "template", "key": "tagId", "value": "G-XXXXXXX" }] } }
55
+ ```
56
+
57
+ GA4 event tag:
58
+ ```json
59
+ { "kind": "tag", "body": { "name": "GA4 - Event - generate_lead", "type": "gaawe", "firingTriggerId": ["Form - submit success"],
60
+ "parameter": [{ "type": "template", "key": "eventName", "value": "generate_lead" },
61
+ { "type": "template", "key": "measurementIdOverride", "value": "G-XXXXXXX" }] } }
62
+ ```
63
+
64
+ Form trigger (site pushes `form_submit` to the data layer on success):
65
+ ```json
66
+ { "kind": "trigger", "body": { "name": "Form - submit success", "type": "customEvent",
67
+ "customEventFilter": [{ "type": "equals", "parameter": [{ "type": "template", "key": "arg0", "value": "{{_event}}" }, { "type": "template", "key": "arg1", "value": "form_submit" }] }] } }
68
+ ```
69
+
70
+ Phone click trigger:
71
+ ```json
72
+ { "kind": "trigger", "body": { "name": "Click - phone", "type": "linkClick",
73
+ "filter": [{ "type": "startsWith", "parameter": [{ "type": "template", "key": "arg0", "value": "{{Click URL}}" }, { "type": "template", "key": "arg1", "value": "tel:" }] }],
74
+ "waitForTags": { "type": "boolean", "value": "false" }, "checkValidation": { "type": "boolean", "value": "false" } } }
75
+ ```
76
+
77
+ Pause a tag (update by name, merge):
78
+ ```json
79
+ { "kind": "tag", "body": { "name": "Old UA tag", "paused": true } }
80
+ ```
81
+
82
+ Built-in variables such as `{{Click URL}}` must be enabled in the container; if a trigger fails to compile on publish, that is the usual reason. Say so and propose enabling it in the UI.
83
+
84
+ ## Key events
85
+
86
+ `ga4_create_key_event` for `conversions.primary_event` needs the token for `ga4.key_event.create` and the `analytics.edit` scope. It is idempotent. Do not mark more than three events as key events on a small site; every extra one dilutes the reports.
87
+
88
+ ## When the client publishes
89
+
90
+ A new version you did not create (daily step) is not a fault. `gtm_snapshot` then `gtm_diff`: if a key-event tag was removed or paused, escalate as tracking-lost. Otherwise record it and move on.
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: gwo-setup
3
+ description: Set up and administer Google Web Operations from the chat. Connect the Google Cloud project, authorise the operations account, register sites, fill in Google ids, record site decisions. Load when the operator asks to connect, add, configure or "set up" anything.
4
+ ---
5
+
6
+ # Setup and administration
7
+
8
+ Everything here changes this system's own configuration, never a client site. Start every setup conversation with `ops_setup_status`: it says what is connected, what each site is missing, and the next step. Do the next step, then call it again. Stop when `nextSteps` holds only optional items.
9
+
10
+ ## Connecting Google (once per installation)
11
+
12
+ 1. **Cloud project.** The operator creates or reuses one Google Cloud project for all client sites (the pilot uses "Google Web Operations" under the company Workspace). In it: enable the Search Console API, Google Analytics Data API, Google Analytics Admin API, Tag Manager API and PageSpeed Insights API; set the OAuth consent screen to Internal; create an OAuth client of type **Desktop app** and download its JSON. Give these steps as a short checklist; do not open the browser for them.
13
+ 2. **Install the client.** Ask for the path of the downloaded JSON and call `google_setup_oauth_client` with it. Never ask the operator to paste the file's contents into the chat.
14
+ 3. **Authorise.** `google_auth_login`. A browser tab opens; the operator signs in with the dedicated operations account (not a personal one) and consents. The tool waits about a minute and then returns; if consent takes longer, `ops_setup_status` shows when the token is stored.
15
+ 4. **Write scopes** are separate and deliberate: `google_auth_login` with `["ga4_write"]` before key events, `["gtm_edit", "gtm_publish"]` before Tag Manager changes. Do not request them until the operator wants those writes.
16
+
17
+ ## Registering a site
18
+
19
+ Ask only for what cannot be inferred: the site URL, the client's name, the platform (Odoo, Next.js, WordPress, other), and for Odoo the ERP URL and database name. Everything else has a default (Hebrew, full tier, `generate_lead`, adapter from platform). Then:
20
+
21
+ 1. `ops_add_site`. It writes `sites/<host>.yaml` with the same layout as every other site.
22
+ 2. `web_scan_site` at 50 pages: the baseline, and the first thing the operator sees about the site.
23
+ 3. Access checklist for the client, in one message they can forward: add the operations account to Search Console (Full), GA4 (Editor), Tag Manager (Publish) if there is a container. The account's address comes from `ops_setup_status`.
24
+ 4. When the client confirms: `google_discover_apply`. It matches properties to sites by host and writes the ids. Matches by name only are reported, never written; confirm those with the operator and set them with `ops_update_site`.
25
+ 5. For Odoo: the operator creates the API user and puts `odoo.login` and `odoo.api_key` into `sites/<host>.secrets.yaml` by hand. Then `site_adapter_ping`. For a repository site: `ops_update_site` with `write.repo.local_path`, then `site_adapter_ping`.
26
+ 6. `ops_setup_status` until the site's `missing` list is empty or only optional.
27
+
28
+ ## Changing a site
29
+
30
+ - One field: `ops_update_site` with the dotted path (`google.tag_manager.public_id`, `contacts.report_to`, `cadence.weekly`, `enabled`). Values that look like JSON are parsed; the rest are strings.
31
+ - A site decision: `ops_add_suppression` with the rule, the path if page-specific, and the reason in the client's words (gwo-operating-model, "Findings are not all faults").
32
+ - Never from chat: `site_id`, anything under `policy`, and any credential. Say so and describe the manual edit.
33
+
34
+ ## Environment
35
+
36
+ `.env` at the repository root, read on every start. `ops_setup_status` shows which are set. Report delivery needs `RESEND_API_KEY`, `GWO_REPORT_FROM`, `GWO_REPORT_TO`; pull requests need `GITHUB_TOKEN`; client reports in the company template need `GWO_DOCX_TEMPLATE`. The operator edits the file; values are never typed into the chat.
37
+
38
+ ## Scheduling
39
+
40
+ Windows: `powershell -ExecutionPolicy Bypass -File tools\schedule.ps1 install` registers the daily and weekly runs; `status` shows them. The tasks run as the operator's Windows user because the Google token is bound to that user.
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: gwo-client-report-template
3
+ description: Structure and tone for the client-facing monthly report. Plain language, three questions, one small table.
4
+ ---
5
+
6
+ # Client report template
7
+
8
+ Write in the client's language (Hebrew for Israeli clients unless the site file says otherwise). One page. The document is produced by `report_client_monthly`; the four fields you pass map to the four sections below, and whatever you leave out is derived from the month's data.
9
+
10
+ ## How the site is doing
11
+
12
+ Two or three sentences. More or fewer visitors from search than last month, and is that expected. One number that matters: clicks from Google this month vs last month.
13
+
14
+ | | This month | Last month | Change |
15
+ |---|---|---|---|
16
+ | Visits from Google | | | |
17
+ | Times shown in results | | | |
18
+ | Average position | | | |
19
+
20
+ ## What we did this month
21
+
22
+ A short list, in business terms. Not "fixed canonical.cross-host on 12 pages" but "fixed a setup issue that was splitting the site's ranking signal between two addresses".
23
+
24
+ ## What we need from you
25
+
26
+ Only things that require the client: approvals for proposed changes, content they need to write, decisions on direction. If nothing, say "nothing this month".
27
+
28
+ ## Next month
29
+
30
+ One or two sentences on the focus.
31
+
32
+ ---
33
+
34
+ Never include: rule ids, tool names, severity labels, raw query lists, or anything the client cannot act on.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Policy engine.
3
+ *
4
+ * Two responsibilities, deliberately separate:
5
+ * classify() answers "what class is this action for this site?"
6
+ * enforce() throws unless the action may proceed right now.
7
+ *
8
+ * Classification is data (policies/default.yaml plus site overrides).
9
+ * Enforcement is the one place execution can be stopped, so every write
10
+ * path must call it. A host prompt cannot downgrade a class; the only way
11
+ * past approval_required is a token the operator produces in-session.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { parse } from 'yaml';
15
+ import { POLICY_FILE } from "../../core/src/paths.js";
16
+ const ORDER = ['auto', 'approval_required', 'protected'];
17
+ let cache;
18
+ export function policy() {
19
+ if (!cache)
20
+ cache = parse(readFileSync(POLICY_FILE, 'utf8'));
21
+ return cache;
22
+ }
23
+ /** For tests: drop the cached policy so a changed file is re-read. */
24
+ export function resetPolicyCache() { cache = undefined; }
25
+ function matchList(list, action) {
26
+ if (!list?.length)
27
+ return null;
28
+ if (list.includes(action))
29
+ return 'exact';
30
+ // Longest prefix wins among prefix matches; caller compares classes.
31
+ return list.some((item) => action.startsWith(item + '.')) ? 'prefix' : null;
32
+ }
33
+ function stricter(a, b) {
34
+ return ORDER.indexOf(a) >= ORDER.indexOf(b) ? a : b;
35
+ }
36
+ /** Resolve the risk class of an action, applying site overrides (tighten only). */
37
+ export function classify(action, site) {
38
+ const p = policy();
39
+ let risk = p.default;
40
+ let source = 'default';
41
+ // Exact matches take priority over prefix matches; among prefix matches
42
+ // the most restrictive class wins so a broad "auto" cannot shadow a
43
+ // narrower "protected".
44
+ const lists = [
45
+ ['protected', p.protected], ['approval_required', p.approval_required], ['auto', p.auto],
46
+ ];
47
+ let exact = null;
48
+ let prefix = null;
49
+ for (const [cls, list] of lists) {
50
+ const m = matchList(list, action);
51
+ if (m === 'exact' && exact === null)
52
+ exact = cls;
53
+ if (m === 'prefix')
54
+ prefix = prefix === null ? cls : stricter(prefix, cls);
55
+ }
56
+ if (exact) {
57
+ risk = exact;
58
+ source = 'exact';
59
+ }
60
+ else if (prefix) {
61
+ risk = prefix;
62
+ source = 'prefix';
63
+ }
64
+ // Site overrides can only raise the class.
65
+ const o = site?.policy?.overrides;
66
+ if (o?.protected?.includes(action) || o?.protected?.some((x) => action.startsWith(x + '.'))) {
67
+ if (risk !== 'protected') {
68
+ risk = 'protected';
69
+ source = 'site';
70
+ }
71
+ }
72
+ else if (o?.approval_required?.includes(action) || o?.approval_required?.some((x) => action.startsWith(x + '.'))) {
73
+ if (risk === 'auto') {
74
+ risk = 'approval_required';
75
+ source = 'site';
76
+ }
77
+ }
78
+ const reason = {
79
+ auto: 'Autonomous by policy.',
80
+ approval_required: 'Requires an operator approval token in this session.',
81
+ protected: 'Protected by policy. Cannot be enabled per site or by instruction.',
82
+ }[risk];
83
+ return { action, risk, source, reason };
84
+ }
85
+ /** Deterministic, human-readable approval token. */
86
+ export function approvalToken(siteId, action) {
87
+ return `${siteId}:${action}`;
88
+ }
89
+ export class PolicyError extends Error {
90
+ decision;
91
+ constructor(decision, message) {
92
+ super(message);
93
+ this.name = 'PolicyError';
94
+ this.decision = decision;
95
+ }
96
+ }
97
+ /**
98
+ * Throw unless the action may proceed. Call this at the top of every write.
99
+ * @param approval token supplied by the operator, if any
100
+ */
101
+ export function enforce(site, action, { approval } = {}) {
102
+ const d = classify(action, site);
103
+ if (d.risk === 'protected') {
104
+ throw new PolicyError(d, `[protected] ${action}: ${d.reason}`);
105
+ }
106
+ if (d.risk === 'approval_required') {
107
+ const expected = approvalToken(site.site_id, action);
108
+ if (approval !== expected) {
109
+ throw new PolicyError(d, `[approval_required] ${action}: ${d.reason} Expected token "${expected}".`);
110
+ }
111
+ }
112
+ return d;
113
+ }
114
+ export function limits() { return policy().limits ?? {}; }
115
+ export function writeRules() { return policy().write_rules ?? {}; }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Per-site persistent state under .gwo-data/<site_id>/.
3
+ *
4
+ * latest-scan.json most recent full scan
5
+ * snapshots/<ts>.json fingerprints for change detection
6
+ * audit.jsonl append-only operational log (runs, writes, blocks)
7
+ *
8
+ * Nothing here is secret. Nothing here is committed.
9
+ */
10
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import { DATA_DIR } from "../../core/src/paths.js";
13
+ function siteDir(siteId) {
14
+ const d = path.join(DATA_DIR, siteId);
15
+ mkdirSync(path.join(d, 'snapshots'), { recursive: true });
16
+ return d;
17
+ }
18
+ const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
19
+ // ---------------------------------------------------------------- scans
20
+ export function saveScan(scan) {
21
+ const d = siteDir(scan.siteId);
22
+ const scanPath = path.join(d, 'latest-scan.json');
23
+ const snapshotPath = path.join(d, 'snapshots', `${stamp()}.json`);
24
+ writeFileSync(scanPath, JSON.stringify(scan, null, 2));
25
+ writeFileSync(snapshotPath, JSON.stringify(fingerprint(scan), null, 2));
26
+ return { scanPath, snapshotPath };
27
+ }
28
+ export function loadScan(siteId) {
29
+ const f = path.join(siteDir(siteId), 'latest-scan.json');
30
+ return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
31
+ }
32
+ // ---------------------------------------------------------------- snapshots
33
+ export function listSnapshots(siteId) {
34
+ return readdirSync(path.join(siteDir(siteId), 'snapshots')).filter((f) => f.endsWith('.json')).sort();
35
+ }
36
+ export function loadSnapshot(siteId, name) {
37
+ const f = path.join(siteDir(siteId), 'snapshots', name);
38
+ return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
39
+ }
40
+ /** Reduce a scan to the fields that matter for change detection. */
41
+ export function fingerprint(scan) {
42
+ const pages = {};
43
+ for (const p of scan.pages) {
44
+ const s = p.signals;
45
+ pages[p.url] = {
46
+ status: p.status,
47
+ title: s?.title ?? null,
48
+ description: s?.description ?? null,
49
+ canonical: s?.canonicalAbs ?? null,
50
+ noindex: s?.noindex ?? false,
51
+ h1: s?.headings.h1[0] ?? null,
52
+ jsonldTypes: s?.jsonldTypes ?? [],
53
+ ga4: s?.analytics.ga4 ?? [],
54
+ gtm: s?.analytics.gtm ?? [],
55
+ wordCount: s?.wordCount ?? 0,
56
+ };
57
+ }
58
+ return {
59
+ at: scan.at,
60
+ origin: scan.origin,
61
+ sitemapCount: scan.sitemap.count,
62
+ findingsBySeverity: scan.summary,
63
+ findingIds: scan.findings.map((f) => `${f.id}|${f.url ?? ''}`).sort(),
64
+ pages,
65
+ };
66
+ }
67
+ /** What changed between two snapshots. */
68
+ export function diffSnapshots(before, after) {
69
+ const out = { newFindings: [], resolvedFindings: [], pageChanges: [], addedPages: [], removedPages: [] };
70
+ const bIds = new Set(before.findingIds), aIds = new Set(after.findingIds);
71
+ for (const id of aIds)
72
+ if (!bIds.has(id))
73
+ out.newFindings.push(id);
74
+ for (const id of bIds)
75
+ if (!aIds.has(id))
76
+ out.resolvedFindings.push(id);
77
+ const SCALAR = ['status', 'title', 'description', 'canonical', 'noindex', 'h1'];
78
+ const LISTS = ['jsonldTypes', 'ga4', 'gtm'];
79
+ for (const [url, a] of Object.entries(after.pages)) {
80
+ const b = before.pages[url];
81
+ if (!b) {
82
+ out.addedPages.push(url);
83
+ continue;
84
+ }
85
+ const changes = [];
86
+ for (const k of [...SCALAR, ...LISTS]) {
87
+ if (JSON.stringify(b[k]) !== JSON.stringify(a[k]))
88
+ changes.push({ field: k, before: b[k], after: a[k] });
89
+ }
90
+ // Word count: only flag material moves, not copy edits.
91
+ const delta = Math.abs(a.wordCount - b.wordCount);
92
+ if (delta > Math.max(100, b.wordCount * 0.3))
93
+ changes.push({ field: 'wordCount', before: b.wordCount, after: a.wordCount });
94
+ if (changes.length)
95
+ out.pageChanges.push({ url, changes });
96
+ }
97
+ for (const url of Object.keys(before.pages))
98
+ if (!after.pages[url])
99
+ out.removedPages.push(url);
100
+ return out;
101
+ }
102
+ // ---------------------------------------------------------------- named snapshots (gtm, ga4-config, ...)
103
+ /** Save a dated record under .gwo-data/<site>/<kind>/<ts>.json. Returns the file name. */
104
+ export function saveNamed(siteId, kind, data) {
105
+ const d = path.join(siteDir(siteId), kind);
106
+ mkdirSync(d, { recursive: true });
107
+ const name = `${stamp()}.json`;
108
+ writeFileSync(path.join(d, name), JSON.stringify(data, null, 2));
109
+ return name;
110
+ }
111
+ export function listNamed(siteId, kind) {
112
+ const d = path.join(siteDir(siteId), kind);
113
+ return existsSync(d) ? readdirSync(d).filter((f) => f.endsWith('.json')).sort() : [];
114
+ }
115
+ export function loadNamed(siteId, kind, name) {
116
+ const f = path.join(siteDir(siteId), kind, name);
117
+ return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
118
+ }
119
+ // ---------------------------------------------------------------- audit log
120
+ export function appendAudit(record) {
121
+ const full = { at: new Date().toISOString(), ...record };
122
+ appendFileSync(path.join(siteDir(record.siteId), 'audit.jsonl'), JSON.stringify(full) + '\n');
123
+ return full;
124
+ }
125
+ export function readAudit(siteId, { limit = 50 } = {}) {
126
+ const f = path.join(siteDir(siteId), 'audit.jsonl');
127
+ if (!existsSync(f))
128
+ return [];
129
+ const lines = readFileSync(f, 'utf8').trim().split('\n').filter(Boolean);
130
+ return lines.slice(-limit).map((l) => JSON.parse(l));
131
+ }
@@ -0,0 +1,90 @@
1
+ # Policy profile: standard-client-site
2
+ #
3
+ # Single source of truth for what the agent may do. Enforced by
4
+ # packages/policy on both hosts. A site's policy.overrides may move an
5
+ # action UP a class (auto -> approval_required -> protected) but never down.
6
+ #
7
+ # Action ids are dotted and hierarchical. A rule for "web.read" also covers
8
+ # "web.read.metadata". Exact matches win over prefix matches.
9
+
10
+ profile: standard-client-site
11
+ version: 2
12
+
13
+ # Anything not listed below.
14
+ default: approval_required
15
+
16
+ # Read-only, or reversible with a tiny blast radius.
17
+ auto:
18
+ - web.read
19
+ - web.scan
20
+ - web.diff
21
+ - google.read
22
+ - gsc.read
23
+ - ga4.read
24
+ - gtm.read
25
+ - pagespeed.read
26
+ - audit.write
27
+ - report.write
28
+ - gsc.sitemap.submit
29
+ - content.meta_description.fill_empty # only when the field is empty
30
+ # This system's own configuration, never a client site. Recorded in the audit log.
31
+ - registry.site.create
32
+ - registry.site.update # site_id, policy and credentials are refused in code
33
+ - google.auth.setup
34
+ - google.auth.login
35
+
36
+ # Requires an approval token "<site_id>:<action>" in the same session.
37
+ approval_required:
38
+ - content.write
39
+ - content.title.update
40
+ - content.meta_description.update
41
+ - content.schema.update
42
+ - content.canonical.update
43
+ - content.robots.update
44
+ - content.redirect.update
45
+ - sitemap.update
46
+ - gtm.stage
47
+ - gtm.publish
48
+ - ga4.key_event.create
49
+ - ga4.config.update
50
+ - github.pr.create
51
+ - cloudflare.config.update
52
+
53
+ # Never executed by the agent. Not even with a token.
54
+ protected:
55
+ - google.property.delete
56
+ - gtm.container.delete
57
+ - gsc.site.delete
58
+ - gtm.workspace.delete_with_changes
59
+ - ga4.data_retention.update
60
+ - analytics.data.delete
61
+ - access.user.remove
62
+ - billing
63
+ - account
64
+ - dns.destructive
65
+ - content.delete
66
+ - legal.update
67
+ - privacy.update
68
+ - auth.security.update
69
+
70
+ # Rules that shape how writes are performed, regardless of class.
71
+ write_rules:
72
+ verify_after_write: true # every write is followed by a read that confirms it
73
+ record_previous_value: true # audit record carries the before state
74
+ gtm:
75
+ always_use_workspace: true
76
+ workspace_name_prefix: gwo-
77
+ require_diff_before_publish: true
78
+ content:
79
+ dry_run_by_default: true
80
+ max_pages_per_run: 25
81
+
82
+ # Operational limits.
83
+ limits:
84
+ crawl:
85
+ max_pages_per_scan: 200
86
+ concurrency: 4
87
+ politeness_delay_ms: 250
88
+ search_console:
89
+ queries_per_minute_per_user: 180
90
+ max_rows_per_query: 25000
@@ -0,0 +1,71 @@
1
+ # Site registry record. One file per site: sites/<host>.yaml
2
+ # Credentials go in sites/<host>.secrets.yaml (gitignored), never here.
3
+ #
4
+ # Required: site_id, name, production_url. Everything else has a default.
5
+
6
+ site_id: example # short stable id, used in tokens and data paths
7
+ name: Example Ltd
8
+ enabled: true
9
+ production_url: https://example.co.il
10
+ alt_hosts: # variants that must redirect to production_url
11
+ - https://www.example.co.il
12
+ locales: [he, en]
13
+ default_locale: he
14
+ timezone: Asia/Jerusalem
15
+ platform: odoo # odoo | nextjs | wordpress | custom
16
+ tier: full # full: we can change the site. advisory: report only.
17
+
18
+ seo:
19
+ sitemap: /sitemap.xml
20
+ robots: /robots.txt
21
+ trailing_slash: false
22
+
23
+ google:
24
+ search_console:
25
+ property: sc-domain:example.co.il # or a URL-prefix property
26
+ ga4:
27
+ measurement_id: G-XXXXXXXXXX # visible in page source
28
+ property_id: "123456789" # numeric; needed for Data/Admin API
29
+ tag_manager:
30
+ public_id: GTM-XXXXXXX
31
+ account_id: null
32
+ container_id: null
33
+
34
+ write:
35
+ adapter: odoo # odoo | repo | none (see docs/SITE-ADAPTERS.md)
36
+ odoo: # when adapter is odoo; login + api_key go in <host>.secrets.yaml
37
+ url: https://erp.example.co.il
38
+ db: example_prod
39
+ website_id: null # set for multi-website databases
40
+ repo: # when adapter is repo; the checkout must contain gwo.seo.json
41
+ url: null
42
+ branch: main
43
+ local_path: null
44
+
45
+ conversions:
46
+ primary_event: generate_lead
47
+
48
+ findings: # site decisions the scanner respects; each needs a reason
49
+ suppress:
50
+ - rule: measurement.no-gtm # exact rule id, or "area.*" for a whole area
51
+ reason: Owner requires GA4 without Tag Manager.
52
+ - rule: content.thin
53
+ path: /contact/ # optional: exact path, or a prefix ending in "*"
54
+ reason: Conversion page, intentionally short.
55
+
56
+ policy:
57
+ profile: standard-client-site
58
+ overrides: # may only tighten the shared policy
59
+ approval_required: []
60
+ protected: []
61
+
62
+ cadence:
63
+ daily: true
64
+ weekly: true
65
+ monthly: true
66
+
67
+ contacts:
68
+ owner: Owner Name
69
+ report_to: []
70
+
71
+ notes: Free text for the operator.