@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,360 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Google Web Operations MCP server.
4
+ *
5
+ * Thin layer over packages/core. The same process serves Claude Code and
6
+ * Codex; host differences are packaging only (see hosts/).
7
+ *
8
+ * stdout is the MCP protocol channel. Anything diagnostic goes to stderr.
9
+ *
10
+ * Tool names use underscores only: some clients reject dots.
11
+ */
12
+ import { McpServer } from '@modelcontextprotocol/server';
13
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
14
+ import * as z from 'zod/v4';
15
+ import { operations } from "../../../packages/core/src/index.js";
16
+ import * as admin from "../../../packages/core/src/admin.js";
17
+ import { renderDoctor } from "../../../packages/core/src/doctor.js";
18
+ import { ga4, gsc, pagespeed, tokenProvider, makeOauthProvider, discover } from "../../../packages/connectors/google/src/index.js";
19
+ import { getSite } from "../../../packages/core/src/registry.js";
20
+ import { openPullRequest } from "../../../packages/connectors/github/src/index.js";
21
+ import { readFileSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { ROOT } from "../../../packages/core/src/paths.js";
24
+ /** The package version, so the server never reports a stale number after a release. */
25
+ const PKG_VERSION = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
26
+ const text = (value) => ({
27
+ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
28
+ });
29
+ const fail = (e) => ({
30
+ isError: true,
31
+ content: [{ type: 'text', text: `error: ${e.message ?? String(e)}` }],
32
+ });
33
+ async function guard(fn) {
34
+ try {
35
+ return text(await fn());
36
+ }
37
+ catch (e) {
38
+ return fail(e);
39
+ }
40
+ }
41
+ /** Resolve the Search Console property for a site, or explain what is missing. */
42
+ function gscProperty(siteId) {
43
+ const p = getSite(siteId).google.search_console?.property;
44
+ if (!p)
45
+ throw new Error(`Site ${siteId} has no google.search_console.property configured.`);
46
+ return p;
47
+ }
48
+ function ga4Property(siteId) {
49
+ const p = getSite(siteId).google.ga4?.property_id;
50
+ if (!p)
51
+ throw new Error(`Site ${siteId} has no google.ga4.property_id configured. The measurement id (G-...) is not enough; the numeric property id is needed.`);
52
+ return p;
53
+ }
54
+ function createServer() {
55
+ const server = new McpServer({ name: 'google-web-operations', version: PKG_VERSION }, {
56
+ instructions: 'Google Web Operations runs SEO and GEO operations for registered client sites. ' +
57
+ 'Observe before changing. Classify every write with ops_classify_action; protected actions are never performed; ' +
58
+ 'approval_required actions need the token the operator gives you in this session. ' +
59
+ 'Google tools need credentials (see docs/SECURITY.md); web_* tools work without them. ' +
60
+ 'If nothing is configured yet, start with ops_setup_status and the gwo-setup skill.',
61
+ });
62
+ // ------------------------------------------------------------ registry
63
+ server.registerTool('ops_list_sites', {
64
+ description: 'List registered sites: id, host, platform, tier, and which Google properties are configured.',
65
+ inputSchema: z.object({ includeDisabled: z.boolean().default(false) }),
66
+ }, async ({ includeDisabled }) => guard(() => operations.listSites({ includeDisabled }).map((s) => ({
67
+ id: s.site_id, name: s.name, enabled: s.enabled, host: s.production_url, platform: s.platform, tier: s.tier,
68
+ gsc: s.google.search_console?.property ?? null, ga4: s.google.ga4?.measurement_id ?? null, gtm: s.google.tag_manager?.public_id ?? null,
69
+ }))));
70
+ server.registerTool('ops_get_site', {
71
+ description: 'Full registry record for one site (no secrets), last scan summary, snapshot count.',
72
+ inputSchema: z.object({ site: z.string().min(1).describe('site id or host') }),
73
+ }, async ({ site }) => guard(() => operations.siteState(site)));
74
+ server.registerTool('ops_classify_action', {
75
+ description: 'Ask policy what class an action is for a site: auto, approval_required (returns the token needed), or protected. ' +
76
+ 'Call before proposing any write. Action ids are dotted, e.g. "content.title.update", "gtm.publish", "gsc.sitemap.submit".',
77
+ inputSchema: z.object({ action: z.string().min(1), site: z.string().optional() }),
78
+ }, async ({ action, site }) => guard(() => operations.classifyAction(action, site)));
79
+ server.registerTool('ops_audit_log', {
80
+ description: 'Operational audit log for a site: runs, proposals, writes with before/after and verification.',
81
+ inputSchema: z.object({ site: z.string().min(1), limit: z.number().int().min(1).max(500).default(50) }),
82
+ }, async ({ site, limit }) => guard(() => operations.auditLog(site, limit)));
83
+ // ------------------------------------------------------------ setup and administration (this system's configuration, never a client site)
84
+ server.registerTool('ops_setup_status', {
85
+ description: 'Where setup stands, in one read: OAuth client installed, authorised account and scopes, which write scopes are present, which .env settings are set, ' +
86
+ 'and per site what is configured and what is missing. Ends with nextSteps, and carries a rendered checklist you can show the operator as is. Start every setup or onboarding conversation here (gwo-setup skill).',
87
+ inputSchema: z.object({}),
88
+ }, async () => guard(() => { const s = admin.setupStatus(); return { ...s, rendered: renderDoctor(s) }; }));
89
+ server.registerTool('google_setup_oauth_client', {
90
+ description: 'Install the OAuth client JSON the operator downloaded from Google Cloud ("Desktop app" type) into secrets/. Pass the file path; never ask for the file contents. Validates the shape and returns the client id only.',
91
+ inputSchema: z.object({ path: z.string().min(1).describe('local path of the downloaded client_secret_*.json') }),
92
+ }, async ({ path }) => guard(() => admin.installOauthClient(path)));
93
+ server.registerTool('google_auth_login', {
94
+ description: 'Run the Google consent flow: opens the browser on this machine for the operations account and stores the refresh token encrypted. Waits about 90 seconds; if consent takes longer it keeps waiting in the background and ops_setup_status shows the result. ' +
95
+ 'scopes: extra short names, "ga4_write" (key events), "gtm_edit" (staging), "gtm_publish" (publishing). Ask for write scopes only when a write is about to be enabled.',
96
+ inputSchema: z.object({ scopes: z.array(z.enum(['ga4_write', 'gtm_edit', 'gtm_publish'])).default([]) }),
97
+ }, async ({ scopes }) => guard(() => admin.authLogin(scopes)));
98
+ server.registerTool('google_discover_apply', {
99
+ description: 'List the Search Console properties, GA4 properties and Tag Manager containers the operations account can see, match them to registered sites by host, and write the ids of confident matches into the site files. ' +
100
+ 'Matches by name only are returned for confirmation, never written (use ops_update_site after the operator confirms).',
101
+ inputSchema: z.object({}),
102
+ }, async () => guard(() => admin.discoverApply()));
103
+ server.registerTool('ops_add_site', {
104
+ description: 'Register a new client site: writes sites/<host>.yaml with the standard layout and defaults (Hebrew, full tier, generate_lead, adapter from platform). Ask only for url, name, platform, and for Odoo the ERP url and database. ' +
105
+ 'Google ids are filled later by google_discover_apply. Credentials never go here. Follow with web_scan_site for the baseline.',
106
+ inputSchema: z.object({
107
+ url: z.string().url(), name: z.string().min(1),
108
+ platform: z.enum(['odoo', 'nextjs', 'wordpress', 'custom']).default('custom'),
109
+ locales: z.array(z.string()).optional(), defaultLocale: z.string().optional(), altHosts: z.array(z.string()).optional(),
110
+ tier: z.enum(['full', 'advisory']).optional(), adapter: z.enum(['odoo', 'repo', 'none']).optional(),
111
+ primaryEvent: z.string().optional(), owner: z.string().optional(), notes: z.string().optional(),
112
+ odoo: z.object({ url: z.string().url(), db: z.string().min(1), websiteId: z.number().int().optional() }).optional(),
113
+ repo: z.object({ url: z.string().optional(), branch: z.string().optional(), localPath: z.string().optional() }).optional(),
114
+ }),
115
+ }, async (input) => guard(() => { const r = admin.addSite(input); return { file: r.file, site: operations.getSite(r.site.site_id) }; }));
116
+ server.registerTool('ops_update_site', {
117
+ description: 'Set one field of a site file by dotted path, keeping the file\'s comments: e.g. "google.tag_manager.public_id", "write.repo.local_path", "contacts.report_to", "cadence.weekly", "enabled". ' +
118
+ 'Values that look like JSON are parsed (numbers, booleans, arrays; quote a string of digits to keep it a string). site_id, policy and credentials are refused.',
119
+ inputSchema: z.object({ site: z.string().min(1), path: z.string().min(1), value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.string())]) }),
120
+ }, async ({ site, path, value }) => guard(() => admin.updateSite(site, path, value)));
121
+ server.registerTool('ops_add_suppression', {
122
+ description: 'Record a site decision: a finding that is intentional on this site (e.g. content.thin on /contact/, measurement.no-gtm). Needs the rule id, an optional path (exact or prefix ending in *), and the reason in the client\'s words. ' +
123
+ 'Suppressed findings stay counted in every scan header. Use after the client or their developer confirms the finding is deliberate; never to hide a problem.',
124
+ inputSchema: z.object({ site: z.string().min(1), rule: z.string().min(1), path: z.string().optional(), reason: z.string().min(3) }),
125
+ }, async ({ site, rule, path, reason }) => guard(() => admin.addSuppression(site, { rule, ...(path ? { path } : {}), reason })));
126
+ // ------------------------------------------------------------ web (no credentials)
127
+ server.registerTool('web_scan_site', {
128
+ description: 'Crawl a site (sitemap or given URLs), run technical SEO and GEO rules, save the scan and a snapshot. ' +
129
+ 'Returns a grouped summary; full data is in .gwo-data/<site>/latest-scan.json. Needs no credentials.',
130
+ inputSchema: z.object({
131
+ site: z.string().min(1),
132
+ maxPages: z.number().int().min(1).max(200).default(50),
133
+ urls: z.array(z.string().url()).optional().describe('crawl exactly these URLs instead of the sitemap'),
134
+ }),
135
+ }, async ({ site, maxPages, urls }) => guard(async () => {
136
+ const { scan, scanPath } = await operations.scanSite(site, urls ? { maxPages, urls } : { maxPages });
137
+ return operations.renderScan(scan) + `\n\nsaved: ${scanPath}`;
138
+ }));
139
+ server.registerTool('web_inspect_url', {
140
+ description: 'Fetch one URL: status, redirect chain, all SEO/GEO signals, page-level findings. Use for spot checks and to verify a fix landed.',
141
+ inputSchema: z.object({ url: z.string().url(), site: z.string().optional().describe('for canonical-host and locale context') }),
142
+ }, async ({ url, site }) => guard(async () => {
143
+ const { page, findings } = await operations.inspectUrl(url, site);
144
+ const s = page.signals;
145
+ return {
146
+ url, status: page.status, finalUrl: page.finalUrl, hops: page.chain.length - 1, ms: page.ms, bytes: page.bytes,
147
+ title: s?.title, description: s?.description, canonical: s?.canonicalAbs, lang: s?.lang, dir: s?.dir,
148
+ h1: s?.headings.h1, jsonldTypes: s?.jsonldTypes, alternates: s?.alternates, analytics: s?.analytics,
149
+ wordCount: s?.wordCount, images: s?.images, links: s?.links, findings,
150
+ };
151
+ }));
152
+ server.registerTool('web_inspect_rendered', {
153
+ description: 'Load one URL in a headless browser and compare what renders with the static HTML: word count ratio, headings, JSON-LD and form fields that only appear after JavaScript. ' +
154
+ 'Verdict server-rendered / partly-js / js-dependent. Use to confirm or dismiss content.thin, and before proposing structured data (JSON-LD injected by JavaScript is invisible to most answer engines). ' +
155
+ 'Needs Playwright on the machine; if missing, the error says exactly what to install.',
156
+ inputSchema: z.object({ url: z.string().url(), site: z.string().optional() }),
157
+ }, async ({ url, site }) => guard(async () => (await operations.inspectRendered(url, site)).rendered));
158
+ server.registerTool('web_diff_scans', {
159
+ description: 'What changed between the two most recent scans: new and resolved findings, page field changes, added and removed pages.',
160
+ inputSchema: z.object({ site: z.string().min(1) }),
161
+ }, async ({ site }) => guard(() => operations.diffLatest(site)));
162
+ // ------------------------------------------------------------ Google (credentials required)
163
+ server.registerTool('google_auth_status', {
164
+ description: 'Which Google credential source is active, which account, which scopes. Never returns a token. If not authorised, tells the operator to run "npm run cli -- auth login".',
165
+ inputSchema: z.object({}),
166
+ }, async () => guard(() => ({ active: tokenProvider().describe(), oauth: makeOauthProvider().status() })));
167
+ server.registerTool('google_discover_properties', {
168
+ description: 'List the Search Console properties, GA4 properties and Tag Manager containers the operations account can see, matched to registered sites by host. ' +
169
+ 'Read-only: returns a proposal. Writing ids into site files is done by the operator with "npm run cli -- discover --apply".',
170
+ inputSchema: z.object({}),
171
+ }, async () => guard(async () => {
172
+ const r = await discover({ apply: false });
173
+ return { inventory: { searchConsole: r.inventory.gsc.map((s) => s.siteUrl), ga4: r.inventory.ga4.map((p) => `${p.property} ${p.displayName}`), tagManager: r.inventory.gtm.map((c) => `${c.publicId} ${c.name}`) }, matches: r.matches };
174
+ }));
175
+ server.registerTool('gsc_performance', {
176
+ description: 'Search Console performance summary for a site and window: totals, top queries and pages, striking-distance queries (position 4-15). Bounded output.',
177
+ inputSchema: z.object({ site: z.string().min(1), startDate: z.string(), endDate: z.string(), top: z.number().int().min(1).max(100).default(20) }),
178
+ }, async ({ site, startDate, endDate, top }) => guard(() => gsc.summarisePerformance(gscProperty(site), startDate, endDate, top)));
179
+ server.registerTool('gsc_search_analytics', {
180
+ description: 'Raw Search Console query. Prefer gsc_performance; use this only for a dimension or filter the summary lacks. rowLimit defaults to 1000.',
181
+ inputSchema: z.object({ site: z.string().min(1), request: z.record(z.string(), z.unknown()) }),
182
+ }, async ({ site, request }) => guard(() => gsc.searchAnalytics(gscProperty(site), request)));
183
+ server.registerTool('gsc_sitemaps', {
184
+ description: 'Sitemaps Search Console knows for the site, with status and counts.',
185
+ inputSchema: z.object({ site: z.string().min(1) }),
186
+ }, async ({ site }) => guard(() => gsc.listSitemaps(gscProperty(site))));
187
+ server.registerTool('gsc_submit_sitemap', {
188
+ description: 'Submit (or resubmit) the site\'s declared sitemap to Search Console. Policy class auto. ' +
189
+ 'Runs the full write loop: reads the sitemap list before, submits, reads after, verifies lastSubmitted advanced, records before/after/verified in the audit log.',
190
+ inputSchema: z.object({ site: z.string().min(1), approval: z.string().optional().describe('only needed if a site override raised the class') }),
191
+ }, async ({ site, approval }) => guard(async () => {
192
+ const r = await operations.submitSitemap(site, approval ? { approval } : {});
193
+ return { status: r.status, verified: r.verified, reason: r.reason, audit: r.audit };
194
+ }));
195
+ server.registerTool('gsc_inspect_url', {
196
+ description: 'Google index status for one URL: verdict, coverage state, canonical Google chose, last crawl, rich results.',
197
+ inputSchema: z.object({ site: z.string().min(1), url: z.string().url() }),
198
+ }, async ({ site, url }) => guard(() => gsc.inspectUrl(gscProperty(site), url)));
199
+ server.registerTool('ga4_traffic', {
200
+ description: 'GA4 sessions, users, key events and engagement by channel for a window.',
201
+ inputSchema: z.object({ site: z.string().min(1), startDate: z.string(), endDate: z.string() }),
202
+ }, async ({ site, startDate, endDate }) => guard(() => ga4.summariseTraffic(ga4Property(site), startDate, endDate)));
203
+ server.registerTool('ga4_run_report', {
204
+ description: 'Raw GA4 Data API runReport. Prefer ga4_traffic. limit defaults to 100.',
205
+ inputSchema: z.object({ site: z.string().min(1), request: z.record(z.string(), z.unknown()) }),
206
+ }, async ({ site, request }) => guard(() => ga4.runReport(ga4Property(site), request)));
207
+ server.registerTool('ga4_realtime', {
208
+ description: 'GA4 realtime report. Use to confirm events are still arriving.',
209
+ inputSchema: z.object({ site: z.string().min(1), request: z.record(z.string(), z.unknown()) }),
210
+ }, async ({ site, request }) => guard(() => ga4.runRealtime(ga4Property(site), request)));
211
+ server.registerTool('ga4_key_events', {
212
+ description: 'Key events (conversions) configured on the GA4 property.',
213
+ inputSchema: z.object({ site: z.string().min(1) }),
214
+ }, async ({ site }) => guard(() => ga4.listKeyEvents(ga4Property(site))));
215
+ // ------------------------------------------------------------ site write adapter (read side)
216
+ server.registerTool('site_adapter_ping', {
217
+ description: 'Check the site\'s write adapter (Odoo JSON-RPC or repository manifest): reachable, authenticated, what it can address. Run during onboarding before declaring a site full tier.',
218
+ inputSchema: z.object({ site: z.string().min(1) }),
219
+ }, async ({ site }) => guard(() => operations.adapterPing(site)));
220
+ server.registerTool('site_pages', {
221
+ description: 'Pages the write adapter can change, with their paths and publish state.',
222
+ inputSchema: z.object({ site: z.string().min(1) }),
223
+ }, async ({ site }) => guard(() => operations.adapterPages(site)));
224
+ server.registerTool('site_page_seo', {
225
+ description: 'SEO fields of one page as the site stores them (title, description, keywords, og image, indexed, published) next to what the live page renders. Use before proposing any content change.',
226
+ inputSchema: z.object({ site: z.string().min(1), path: z.string().min(1).describe('site path, e.g. "/he/services"') }),
227
+ }, async ({ site, path }) => guard(() => operations.adapterPageSeo(site, path)));
228
+ // ------------------------------------------------------------ content writes
229
+ server.registerTool('content_set_meta_description', {
230
+ description: 'Set a page\'s meta description through the site adapter. Write the text yourself per the gwo-content-brief skill; the tool validates it ' +
231
+ '(Hebrew 120-150 chars, Latin 70-160, no stuffing, no HTML), then runs the write loop. Empty field: policy auto. Existing text: approval_required, ' +
232
+ 'pass the token from ops_classify_action for "content.meta_description.update". Verified by reading the stored value back; confirm the live page separately with web_inspect_url.',
233
+ inputSchema: z.object({ site: z.string().min(1), path: z.string().min(1), text: z.string().min(1), approval: z.string().optional() }),
234
+ }, async ({ site, path, text, approval }) => guard(async () => {
235
+ const r = await operations.setMetaDescription(site, path, text, approval ? { approval } : {});
236
+ if (r.status === 'invalid')
237
+ return { status: 'invalid', problems: r.problems };
238
+ return { status: r.status, verified: r.verified, reason: r.reason, audit: r.audit };
239
+ }));
240
+ server.registerTool('content_set_title', {
241
+ description: 'Set a page\'s title through the site adapter. approval_required on every site: pass the token for "content.title.update". ' +
242
+ 'Write it per gwo-content-brief (aim 50-60 characters, primary term first, brand last); the tool rejects under 15 or over 65, HTML, line breaks, repeated terms. ' +
243
+ 'Never change a title on a page that ranks without a Search Console reason. Verified by reading the stored value back.',
244
+ inputSchema: z.object({ site: z.string().min(1), path: z.string().min(1), text: z.string().min(1), approval: z.string().optional() }),
245
+ }, async ({ site, path, text, approval }) => guard(async () => {
246
+ const r = await operations.setTitle(site, path, text, approval ? { approval } : {});
247
+ if (r.status === 'invalid')
248
+ return { status: 'invalid', problems: r.problems };
249
+ return { status: r.status, verified: r.verified, reason: r.reason, audit: r.audit };
250
+ }));
251
+ server.registerTool('site_page_schema', {
252
+ description: 'Structured data of one page: the JSON-LD blocks this system manages (as the site stores them) next to every JSON-LD block the live page emits, ' +
253
+ 'including ones the theme adds on its own. Use before proposing schema so you never duplicate a type the page already has.',
254
+ inputSchema: z.object({ site: z.string().min(1), path: z.string().min(1) }),
255
+ }, async ({ site, path }) => guard(() => operations.adapterPageSchema(site, path)));
256
+ server.registerTool('content_set_schema', {
257
+ description: 'Replace the managed JSON-LD blocks on a page through the site adapter (Odoo: the page view\'s arch; repo: the manifest entry\'s schema array). ' +
258
+ 'approval_required on every site: pass the token for "content.schema.update". Blocks are validated (schema.org @context, @type, FAQPage shape, size). ' +
259
+ 'An empty list removes the managed blocks. Verified by reading back; confirm the live page with web_inspect_url after deploy.',
260
+ inputSchema: z.object({ site: z.string().min(1), path: z.string().min(1), blocks: z.array(z.record(z.string(), z.unknown())), approval: z.string().optional() }),
261
+ }, async ({ site, path, blocks, approval }) => guard(async () => {
262
+ const r = await operations.setSchema(site, path, blocks, approval ? { approval } : {});
263
+ if (r.status === 'invalid')
264
+ return { status: 'invalid', problems: r.problems };
265
+ return { status: r.status, verified: r.verified, reason: r.reason, audit: r.audit };
266
+ }));
267
+ server.registerTool('ga4_create_key_event', {
268
+ description: 'Mark a GA4 event as a key event (conversion). approval_required: pass the token for "ga4.key_event.create". Idempotent. ' +
269
+ 'Needs the analytics.edit scope; the error tells the operator how to add it.',
270
+ inputSchema: z.object({ site: z.string().min(1), eventName: z.string().min(1), approval: z.string().optional() }),
271
+ }, async ({ site, eventName, approval }) => guard(async () => {
272
+ const r = await operations.createKeyEvent(site, eventName, approval ? { approval } : {});
273
+ return { status: r.status, verified: r.verified, reason: r.reason, audit: r.audit };
274
+ }));
275
+ // ------------------------------------------------------------ repository pull requests
276
+ server.registerTool('repo_open_pr', {
277
+ description: 'Commit the checkout\'s pending manifest changes for a repository-backed site, push a branch and open a pull request (idempotent per day). ' +
278
+ 'approval_required: pass the token for "github.pr.create". Needs GITHUB_TOKEN. Pass the list of changes made so the PR body explains them.',
279
+ inputSchema: z.object({ site: z.string().min(1), changes: z.array(z.string()).default([]), approval: z.string().optional() }),
280
+ }, async ({ site, changes, approval }) => guard(async () => {
281
+ const r = await openPullRequest(site, { changes, ...(approval ? { approval } : {}) });
282
+ return { status: r.status, verified: r.verified, reason: r.reason, pr: r.after, audit: r.audit };
283
+ }));
284
+ // ------------------------------------------------------------ reports
285
+ server.registerTool('report_client_monthly', {
286
+ description: 'Write the monthly client report as a Word document (.docx, inside the company template when GWO_DOCX_TEMPLATE is set) plus a Markdown twin, under .gwo-data/<site>/reports/. ' +
287
+ 'Gathers the numbers itself: Search Console and GA4 totals for the month and the month before, scan health now and a month ago, and what the audit log says was done or is waiting. ' +
288
+ 'You write the narrative per gwo-client-report-template in the client\'s language: headline (how the site is doing), did (business terms, no rule ids or tool names), need (what the client must do), next. ' +
289
+ 'Any field you omit is derived from the data. month defaults to last month. Returns the Markdown so you can review it before sending.',
290
+ inputSchema: z.object({
291
+ site: z.string().min(1),
292
+ month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
293
+ headline: z.string().optional(), did: z.array(z.string()).optional(), need: z.array(z.string()).optional(), next: z.string().optional(),
294
+ lang: z.enum(['he', 'en']).optional().describe('defaults to the site\'s default locale'),
295
+ }),
296
+ }, async ({ site, month, headline, did, need, next, lang }) => guard(async () => {
297
+ const r = await operations.renderClientReport(site, { ...(month ? { month } : {}), ...(lang ? { lang } : {}),
298
+ narrative: { ...(headline ? { headline } : {}), ...(did ? { did } : {}), ...(need ? { need } : {}), ...(next ? { next } : {}) } });
299
+ return `${r.markdown}\n\n---\ndocx: ${r.docx}\nmd: ${r.md}\ntemplate: ${r.template ?? 'none (set GWO_DOCX_TEMPLATE)'}${r.notes.length ? `\nnotes: ${r.notes.join('; ')}` : ''}`;
300
+ }));
301
+ // ------------------------------------------------------------ Tag Manager (read)
302
+ server.registerTool('gtm_containers', {
303
+ description: 'Every Tag Manager container the operations account can see, across accounts. Use to find a client\'s container id after they grant access.',
304
+ inputSchema: z.object({}),
305
+ }, async () => guard(() => operations.gtmContainers()));
306
+ server.registerTool('gtm_versions', {
307
+ description: 'Version history of a site\'s container. A new version you did not create is a change to verify. Daily playbook step.',
308
+ inputSchema: z.object({ site: z.string().min(1) }),
309
+ }, async ({ site }) => guard(() => operations.gtmVersions(site)));
310
+ server.registerTool('gtm_snapshot', {
311
+ description: 'Snapshot the live container: tags with their firing triggers, paused tags, triggers, variables. Saved for diffing. Returns a readable summary.',
312
+ inputSchema: z.object({ site: z.string().min(1) }),
313
+ }, async ({ site }) => guard(async () => (await operations.gtmSnapshot(site)).rendered));
314
+ server.registerTool('gtm_diff', {
315
+ description: 'What changed between the two most recent GTM snapshots: version, tags added/removed/changed (including paused), triggers, variables.',
316
+ inputSchema: z.object({ site: z.string().min(1) }),
317
+ }, async ({ site }) => guard(() => operations.gtmDiff(site)));
318
+ // ------------------------------------------------------------ Tag Manager (staged writes)
319
+ server.registerTool('gtm_workspaces', {
320
+ description: 'Workspaces of the site\'s container with pending change counts and merge conflicts. Shows what is staged (ours start with the policy prefix, gwo-) and whether the client left work of their own.',
321
+ inputSchema: z.object({ site: z.string().min(1) }),
322
+ }, async ({ site }) => guard(() => operations.gtmWorkspaces(site)));
323
+ server.registerTool('gtm_stage', {
324
+ description: 'Stage tags, triggers or variables into the site\'s gwo- workspace (created if missing). Nothing goes live. approval_required: pass the token for "gtm.stage". ' +
325
+ 'Each entity is a GTM API resource body without ids (name, type, parameter[], firingTriggerId[] ...); a name that already exists in the workspace is updated by merge, a new name is created. ' +
326
+ 'Tags may list trigger NAMES in firingTriggerId; they resolve against the workspace and the built-ins ("All Pages"). Triggers are staged before tags. ' +
327
+ 'Every entity is a verified write with its own audit record. Returns the workspace diff against the live version and the review token gtm_publish needs. See the gwo-measurement skill for standard bodies.',
328
+ inputSchema: z.object({
329
+ site: z.string().min(1),
330
+ entities: z.array(z.object({ kind: z.enum(['tag', 'trigger', 'variable']), body: z.record(z.string(), z.unknown()) })).min(1),
331
+ reason: z.string().optional().describe('one line for the workspace description and audit log'),
332
+ workspaceId: z.string().optional().describe('stage into this gwo- workspace instead of the default one'),
333
+ approval: z.string().optional(),
334
+ }),
335
+ }, async ({ site, entities, reason, workspaceId, approval }) => guard(async () => {
336
+ const r = await operations.gtmStage(site, entities, { ...(approval ? { approval } : {}), ...(reason ? { reason } : {}), ...(workspaceId ? { workspaceId } : {}) });
337
+ return r.rendered + '\n\n' + JSON.stringify({ workspace: r.workspace, results: r.results, reviewToken: r.reviewToken }, null, 2);
338
+ }));
339
+ server.registerTool('gtm_workspace_diff', {
340
+ description: 'What the gwo- workspace would change in the live container: added, updated (with the fields), deleted entities, merge conflicts, and the review token. Read-only. Present this to the operator before asking for the publish token.',
341
+ inputSchema: z.object({ site: z.string().min(1), workspaceId: z.string().optional() }),
342
+ }, async ({ site, workspaceId }) => guard(async () => {
343
+ const r = await operations.gtmWorkspaceDiff(site, workspaceId);
344
+ return 'error' in r ? r : r.rendered + '\n\n' + JSON.stringify({ workspace: r.workspace, reviewToken: r.reviewToken, counts: r.diff.counts }, null, 2);
345
+ }));
346
+ server.registerTool('gtm_publish', {
347
+ description: 'Create a container version from the gwo- workspace and publish it. Two gates, both enforced in code: the approval token for "gtm.publish" and the review token from gtm_workspace_diff, ' +
348
+ 'which only matches if the workspace and the live version are exactly what was reviewed. Syncs first; merge conflicts or compiler errors abort. Verified by reading the live version after; a snapshot is saved.',
349
+ inputSchema: z.object({ site: z.string().min(1), review: z.string().min(1), workspaceId: z.string().optional(), versionName: z.string().optional(), notes: z.string().optional(), approval: z.string().optional() }),
350
+ }, async ({ site, review, workspaceId, versionName, notes, approval }) => guard(() => operations.gtmPublish(site, {
351
+ review, ...(workspaceId ? { workspaceId } : {}), ...(versionName ? { versionName } : {}), ...(notes ? { notes } : {}), ...(approval ? { approval } : {}),
352
+ })));
353
+ server.registerTool('pagespeed_check', {
354
+ description: 'PageSpeed Insights summary: category scores, lab metrics, and real-user Core Web Vitals when available.',
355
+ inputSchema: z.object({ url: z.string().url(), strategy: z.enum(['mobile', 'desktop']).default('mobile') }),
356
+ }, async ({ url, strategy }) => guard(() => pagespeed.summarisePageSpeed(url, strategy)));
357
+ return server;
358
+ }
359
+ void serveStdio(createServer);
360
+ console.error('google-web-operations MCP server ready on stdio');
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scheduled operations worker: the sensing half of a playbook, without an agent.
4
+ *
5
+ * node apps/worker/src/index.ts [daily|weekly|monthly] [site-id] [--deliver]
6
+ *
7
+ * For every enabled site (or one): scan, diff against the previous snapshot,
8
+ * apply the escalation rules, and write a report. With --deliver and Resend
9
+ * configured, the report is emailed; otherwise it is printed. Exit code 1 if
10
+ * any site failed to scan, 2 if any site escalated, so a scheduler can alert.
11
+ */
12
+ import { mkdirSync, writeFileSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { listSites } from "../../../packages/core/src/registry.js";
15
+ import { scanSite } from "../../../packages/core/src/operations.js";
16
+ import { escalations } from "../../../packages/core/src/escalate.js";
17
+ import { DATA_DIR } from "../../../packages/core/src/paths.js";
18
+ import * as storage from "../../../packages/storage/src/index.js";
19
+ import * as resend from "../../../packages/connectors/resend/src/index.js";
20
+ const PAGES = { daily: 25, weekly: 200, monthly: 200 };
21
+ const args = process.argv.slice(2);
22
+ const deliver = args.includes('--deliver');
23
+ const positional = args.filter((a) => !a.startsWith('--'));
24
+ const cadence = ['daily', 'weekly', 'monthly'].find((c) => c === positional[0]) ?? 'daily';
25
+ const only = positional[1];
26
+ const lines = [];
27
+ const sites = listSites().filter((s) => s.cadence[cadence] && (!only || s.site_id === only));
28
+ for (const site of sites) {
29
+ const t0 = Date.now();
30
+ try {
31
+ const { scan } = await scanSite(site.site_id, { maxPages: PAGES[cadence] });
32
+ const snaps = storage.listSnapshots(site.site_id);
33
+ let esc = [];
34
+ let changed = 0;
35
+ if (snaps.length >= 2) {
36
+ const b = storage.loadSnapshot(site.site_id, snaps.at(-2));
37
+ const a = storage.loadSnapshot(site.site_id, snaps.at(-1));
38
+ if (b && a) {
39
+ const diff = storage.diffSnapshots(b, a);
40
+ changed = diff.newFindings.length + diff.pageChanges.length + diff.removedPages.length;
41
+ esc = escalations(b, a, diff);
42
+ }
43
+ }
44
+ const s = scan.summary;
45
+ const summary = `${scan.pagesCrawled} pages, ${s.blocker}/${s.high}/${s.medium} blocker/high/medium, ${changed} change${changed === 1 ? '' : 's'} since last scan`;
46
+ storage.appendAudit({ siteId: site.site_id, operation: `worker.${cadence}`, status: 'observed', risk: 'auto',
47
+ summary: `${cadence} run: ${summary}${esc.length ? `; ${esc.length} escalation(s)` : ''}`, data: { summary: s, changed, escalations: esc, ms: Date.now() - t0 } });
48
+ lines.push({ siteId: site.site_id, ok: true, summary, escalations: esc });
49
+ }
50
+ catch (e) {
51
+ const error = e.message;
52
+ storage.appendAudit({ siteId: site.site_id, operation: `worker.${cadence}`, status: 'failure', risk: 'auto', summary: error });
53
+ lines.push({ siteId: site.site_id, ok: false, escalations: [], error });
54
+ }
55
+ }
56
+ // ---------------------------------------------------------------- report
57
+ const failures = lines.filter((l) => !l.ok).length;
58
+ const escalated = lines.filter((l) => l.escalations.length).length;
59
+ const stamp = new Date().toISOString();
60
+ const title = `GWeb Ops ${cadence} run, ${stamp.slice(0, 16).replace('T', ' ')} UTC: ${sites.length} site${sites.length === 1 ? '' : 's'}, ${escalated} escalated, ${failures} failed`;
61
+ const report = [title, ''];
62
+ for (const l of lines) {
63
+ if (!l.ok) {
64
+ report.push(`${l.siteId}: FAILED. ${l.error}`);
65
+ continue;
66
+ }
67
+ if (!l.escalations.length) {
68
+ report.push(`${l.siteId}: healthy. ${l.summary}.`);
69
+ continue;
70
+ }
71
+ report.push(`${l.siteId}: ESCALATE. ${l.summary}.`);
72
+ for (const e of l.escalations)
73
+ report.push(` - ${e.kind}${e.url ? ` ${e.url}` : ''}: ${e.detail}`);
74
+ }
75
+ report.push('', `Full scans and audit logs: ${DATA_DIR}`);
76
+ const text = report.join('\n');
77
+ const runsDir = path.join(DATA_DIR, '_runs');
78
+ mkdirSync(runsDir, { recursive: true });
79
+ const file = path.join(runsDir, `${cadence}-${stamp.replace(/[:.]/g, '-')}.txt`);
80
+ writeFileSync(file, text + '\n');
81
+ console.log(text);
82
+ if (deliver) {
83
+ if (resend.isConfigured()) {
84
+ try {
85
+ const r = await resend.send({ subject: title, text });
86
+ console.error(`delivered: ${r.id}`);
87
+ }
88
+ catch (e) {
89
+ console.error(`delivery failed: ${e.message}`);
90
+ }
91
+ }
92
+ else {
93
+ console.error(`delivery skipped: ${resend.describe()}`);
94
+ }
95
+ }
96
+ process.exitCode = failures ? 1 : escalated ? 2 : 0;
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Long-running scheduler for hosts without Task Scheduler or cron: a
4
+ * container, a small VM, a NAS. Runs the daily worker at GWO_DAILY_AT
5
+ * (default 07:00) every day and the weekly worker at GWO_WEEKLY_AT (default
6
+ * Monday 07:30) in the process time zone (set TZ=Asia/Jerusalem). Delivery
7
+ * is on when RESEND_API_KEY is configured.
8
+ *
9
+ * gwo worker loop
10
+ *
11
+ * Each run is a child process, so a crash in one run never stops the loop,
12
+ * and the exit code of every run is logged.
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ const workerEntry = ['index.js', 'index.ts'].map((f) => path.join(import.meta.dirname, f)).find((f) => existsSync(f));
18
+ const at = (s, fallback) => { const m = (s ?? fallback).match(/^(\d{1,2}):(\d{2})$/); return m ? { h: Number(m[1]), m: Number(m[2]) } : { h: 7, m: 0 }; };
19
+ const daily = at(process.env['GWO_DAILY_AT'], '07:00');
20
+ const weekly = at(process.env['GWO_WEEKLY_AT'], '07:30');
21
+ const weeklyDay = Number(process.env['GWO_WEEKLY_DAY'] ?? '1'); // 0 Sunday .. 6 Saturday; default Monday
22
+ const deliver = !!process.env['RESEND_API_KEY'];
23
+ const log = (s) => console.log(`${new Date().toISOString()} ${s}`);
24
+ let lastDaily = '', lastWeekly = '';
25
+ const stamp = (d) => d.toISOString().slice(0, 10);
26
+ function run(cadence) {
27
+ log(`run ${cadence}${deliver ? ' --deliver' : ''}`);
28
+ const r = spawnSync(process.execPath, [workerEntry, cadence, ...(deliver ? ['--deliver'] : [])], { stdio: 'inherit' });
29
+ log(`${cadence} finished with exit code ${r.status ?? 'null'} (0 healthy, 2 escalations, 1 failures)`);
30
+ }
31
+ log(`worker loop: daily ${daily.h}:${String(daily.m).padStart(2, '0')}, weekly day ${weeklyDay} ${weekly.h}:${String(weekly.m).padStart(2, '0')}, TZ ${process.env['TZ'] ?? 'system'}, delivery ${deliver ? 'on' : 'off'}`);
32
+ setInterval(() => {
33
+ const now = new Date();
34
+ const today = stamp(now);
35
+ if (now.getDay() === weeklyDay && now.getHours() === weekly.h && now.getMinutes() >= weekly.m && lastWeekly !== today) {
36
+ lastWeekly = today;
37
+ run('weekly');
38
+ return;
39
+ }
40
+ if (now.getHours() === daily.h && now.getMinutes() >= daily.m && lastDaily !== today) {
41
+ lastDaily = today;
42
+ run('daily');
43
+ }
44
+ }, 30_000).unref();
45
+ // Keep the process alive without busy-waiting.
46
+ setInterval(() => { }, 1 << 30);
package/bin/gwo.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gwo: the one entry point of the installed package.
4
+ *
5
+ * gwo install [claude|codex|all] [--print] register the MCP server, skills and commands
6
+ * gwo uninstall [claude|codex|all] remove them (operator state in GWO_HOME stays)
7
+ * gwo mcp run the MCP server on stdio (what the hosts launch)
8
+ * gwo worker daily|weekly|monthly [--deliver]
9
+ * gwo worker loop built-in scheduler for containers and VMs (docs/HOSTED-WORKER.md)
10
+ * gwo schedule install|status|remove Windows Task Scheduler for the worker
11
+ * gwo update [spec] npm i -g the latest published version, then gwo install
12
+ * gwo <cli command> everything else: sites, scan, setup, auth, gtm, content, report ...
13
+ *
14
+ * Plain JavaScript so npm's bin shim can load it on any Node; the rest of the
15
+ * package is TypeScript that Node 22.18+ runs natively.
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+ import { existsSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+
22
+ const [major, minor] = process.versions.node.split('.').map(Number);
23
+ if (major < 22 || (major === 22 && minor < 18)) {
24
+ console.error(`Google Web Operations needs Node 22.18 or newer (found ${process.versions.node}).`);
25
+ process.exit(1);
26
+ }
27
+
28
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const cmd = process.argv[2];
30
+ // A checkout runs the TypeScript sources; the packaged build ships JavaScript.
31
+ const ext = existsSync(path.join(root, 'apps', 'cli', 'src', 'index.js')) ? 'index.js' : 'index.ts';
32
+ // Dynamic imports need file URLs on Windows; a bare drive path is read as a URL scheme.
33
+ const load = (...parts) => import(pathToFileURL(path.join(root, ...parts)).href);
34
+
35
+ switch (cmd) {
36
+ case 'install':
37
+ case 'uninstall':
38
+ await load('tools', ext === 'index.js' ? 'install.js' : 'install.ts');
39
+ break;
40
+ case 'mcp':
41
+ await load('apps', 'mcp-server', 'src', ext);
42
+ break;
43
+ case 'worker':
44
+ process.argv.splice(2, 1);
45
+ if (process.argv[2] === 'loop') await load('apps', 'worker', 'src', ext === 'index.js' ? 'loop.js' : 'loop.ts');
46
+ else await load('apps', 'worker', 'src', ext);
47
+ break;
48
+ case 'update': {
49
+ // Latest published version, then re-register the hosts from the new package location.
50
+ const spec = process.argv[3] ?? '@aiguru/google-web-operations@latest';
51
+ const r = spawnSync(`npm install -g ${spec}`, { stdio: 'inherit', shell: true });
52
+ if (r.status !== 0) process.exit(r.status ?? 1);
53
+ const g = spawnSync('npm root -g', { encoding: 'utf8', shell: true });
54
+ const fresh = path.join(g.stdout.trim(), '@aiguru', 'google-web-operations', 'bin', 'gwo.js');
55
+ const i = spawnSync(process.execPath, [fresh, 'install'], { stdio: 'inherit' });
56
+ process.exit(i.status ?? 1);
57
+ break;
58
+ }
59
+ case 'schedule': {
60
+ if (process.platform !== 'win32') { console.error('gwo schedule is Windows only; use cron with "gwo worker daily" elsewhere.'); process.exit(1); }
61
+ const r = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', path.join(root, 'tools', 'schedule.ps1'), process.argv[3] ?? 'status'], { stdio: 'inherit' });
62
+ process.exit(r.status ?? 1);
63
+ break;
64
+ }
65
+ default:
66
+ await load('apps', 'cli', 'src', ext);
67
+ }
@@ -0,0 +1,57 @@
1
+ # Bootstrap prompt for the next implementation session
2
+
3
+ You are continuing the **Google Web Operations** repository (v0.6). This applies whether you are Claude Code or Codex.
4
+
5
+ ## Architecture is decided
6
+
7
+ Read, in order: `README.md`, `docs/PRODUCT-TECHNICAL-SPEC-v0.6.md`, `docs/adr/*.md`, `docs/SECURITY.md`, `docs/SITE-ADAPTERS.md`. Do not re-litigate them. In particular:
8
+
9
+ - One core, one MCP server, hosts are generated. Never edit `hosts/`.
10
+ - Playbooks live in `packages/playbooks/` only. Run `npm run build:hosts` after editing.
11
+ - Policy is data in `policies/default.yaml`; enforcement is `packages/policy`. Every write goes through `performWrite()` in `packages/core/src/writes.ts`. Never call a connector's mutating function directly.
12
+ - A Tag Manager publish needs the approval token and the review token (ADR-005). Do not add a way around either.
13
+ - The package installs with `gwo install` and keeps state in `GWO_HOME` (ADR-006). New state must go under `HOME_DIR` from `paths.ts`, never under `ROOT`. Anything the code reads from `ROOT` at runtime (a new asset directory) must also be copied by `tools/build-package.ts`, or the installed package will not find it.
14
+ - Releasing: bump `package.json`, `git tag v<version>`, push the tag; the release workflow publishes to GitHub Packages and attaches the tarball. Installed machines run `gwo update`.
15
+ - TypeScript runs natively on Node. Import with `.ts` extensions, `import type` for types, no constructor parameter properties, no enums.
16
+ - Load the project skills `seo-geo` and `hebrew-content-writer` (under `.claude/skills/`) before writing any client-facing text; the `gwo-content-brief` playbook is their distilled form.
17
+
18
+ ## Before changing anything
19
+
20
+ ```
21
+ npm install
22
+ npm run check # typecheck, tests, regenerate hosts
23
+ npm run smoke:mcp
24
+ npm run cli -- scan doritgrinberg --pages 30
25
+ node apps/worker/src/index.ts daily
26
+ ```
27
+
28
+ All pass at the time of writing. If one does not, fix that first.
29
+
30
+ ## Operator actions that unlock live paths (not code work)
31
+
32
+ All of these can now be driven from the chat with the `gwo-setup` skill (`ops_setup_status` first). The CLI forms:
33
+
34
+ 1. `npm run cli -- auth login --scope ga4_write`, then `npm run cli -- ga4 key-event <site> <event> --approval <site>:ga4.key_event.create`.
35
+ 2. For each repository-backed site: set `write.repo.local_path` and `write.repo.url`, add `gwo.seo.json` to the site (see `docs/SITE-ADAPTERS.md`), then `npm run cli -- site ping <site>`.
36
+ 3. For each site with a container: set `google.tag_manager.public_id` (or run `discover --apply`), then `npm run cli -- auth login --scope gtm_edit --scope gtm_publish` once. `npm run cli -- gtm workspaces <site>` is the first read.
37
+ 4. `RESEND_API_KEY`, `GWO_REPORT_FROM`, `GWO_REPORT_TO` in `.env` for delivery; `GITHUB_TOKEN` for pull requests.
38
+ 5. `powershell -ExecutionPolicy Bypass -File tools\schedule.ps1 install` registers the daily and weekly tasks; `status` shows them.
39
+
40
+ ## First live use of the v0.4 writes
41
+
42
+ Before the first real client container, run the whole protocol once on a container the company owns: `gtm stage` the standard stack from `gwo-measurement`, `gtm wdiff`, `gtm publish`, then `gtm versions` and `ga4_realtime`. The staged path is tested against a fake API; one live pass will surface any API field the fake does not model (built-in variables, consent settings, folder ids). Fix the connector, not the playbook.
43
+
44
+ Run `npm run cli -- report client <site> --month <YYYY-MM>` once with `GWO_DOCX_TEMPLATE` set and open the .docx in Word: the template's header and footer must be there and the body in Hebrew, right to left.
45
+
46
+ Likewise `content schema` on one Odoo page: confirm the API user can write `ir.ui.view.arch` (Website Designer rights), and that the rendered page shows the block.
47
+
48
+ ## Next milestone: v0.7
49
+
50
+ 1. **First live Tag Manager cycle** on a company-owned container (see above), then the first client container. Fix the connector for whatever the fake API did not model.
51
+ 2. **Install feedback.** The package is being installed on a second Claude Code machine and on Codex. Whatever confuses a person there goes into `gwo-setup`, `gwo doctor` or `docs/GETTING-STARTED.he.md`, not into a chat answer.
52
+ 3. **Outcome metrics.** The client report says what was done; it should say what it changed: clicks and impressions for the pages touched, before and after, from Search Console. Audit records carry the URL and date of every write, so the data is there.
53
+ 4. **Advisory profile.** A second policy profile for advisory-tier clients where nothing is `auto` except reads and reports.
54
+
55
+ ## Definition of done for a session
56
+
57
+ Report only what was implemented and verified: files changed, commands run and their output, host results, what remains blocked. Update the spec's delivered table when scope moves, and bump the version when a milestone completes.