From 7353e08c369195f51cdd37bdf0479dae3901415b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 12:50:25 +0100 Subject: [PATCH] docs(32-05): complete SEO management over MCP, batches, undo and CSV plan --- .../phases/32-seo-foundation/32-05-SUMMARY.md | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 .planning/phases/32-seo-foundation/32-05-SUMMARY.md diff --git a/.planning/phases/32-seo-foundation/32-05-SUMMARY.md b/.planning/phases/32-seo-foundation/32-05-SUMMARY.md new file mode 100644 index 00000000..17d68f7b --- /dev/null +++ b/.planning/phases/32-seo-foundation/32-05-SUMMARY.md @@ -0,0 +1,399 @@ +--- +phase: 32-seo-foundation +plan: 05 +subsystem: sm-seo-plugin +tags: [seo, sm-seo-plugin, mcp, pier, batch, undo, csv, hmac, custom-elements, sm-demo-app, phase-gate] + +requires: + - phase: 32-03 + provides: classes.Service, Resolver with sources, history (Record, WithVia, ViaOf with bouncer.Dispatched), golem15.seo.manage + - phase: 32-04 + provides: SaveRedirect, CheckRedirect, DeleteRedirect, ResolveNotFound, LoopError/ProblemsError, robots.txt screen and tester + - phase: 19.7 + provides: pier (pact.HasMCP, tool binding to routes, read/write ceiling), cabana.ResolveBackend with the dispatch marker +provides: + - "/_seo/api/v1 management API (bucket seo-api): seo, rules, redirects, notfound actions, robots and its tester, settings, history, undo, batch, export, import, issues" + - "29 MCP tools (seo_ prefix), backend guard, golem15.seo.manage; GET tools read-only" + - "AdminOperations (operations.go) and TestEveryAdminOperationHasTool / TestMCPReadToolsAreGET" + - "URL addressing (FindOwner, SetURLSEO): record behind the URL, else an exact URL rule; force_rule" + - "RunBatch: dry run by rollback, all or nothing, per-change diff and conflicts (loop refused; chain, noindex_published, canonical_redirected, canonical_noindex warnings)" + - "Undo of a change and of a batch with undo_stale, undo_loop, undo_gone; History record actions undo and undo_batch" + - "CSV export/import (redirects, rules, records by URL): BOM, , or ;, formula escaping, preview, HMAC-SHA256 apply token with a data fingerprint (transfer.stale)" + - "golem15.seo.transfer singleton with the seo_transfer widget action and element E6 golem15-seo-transfer; navigation Import and export" + - "CMS-data issue report (classes.Issues, GET /issues)" + - "scripts/check-phase32.sh (self-test, go, plugins, consumers, docker, admin, docs, marker)" + - ".planning/phases/32-seo-foundation/32-05-demo-mount.patch (applied by plan 32-06 after the tags)" +affects: [32-06, 33, sm-demo-app, vue-seo-layer] + +actuals: + tokens: 80181 + tasks: 4 + commits: 2 +plan_head_before: f96d7d4b4a91821bb184509a19fe9ff2dd51c3d5 +plan_head_after: 20d02d722a5f46437c7d96c51965d41ad51ec127 + +tech-stack: + added: [] + patterns: + - "Management route wrapper (controllers/api manage): cabana.ResolveBackend (token or MCP dispatch) + cabana.Allows(golem15.seo.manage), typed refusals mapped to 422 redirect_loop / validation_failed, 404, 409" + - "classes.WithTx puts the API/batch transaction on the context so the resolver and every service read see uncommitted batch changes" + - "Batch changes run under per-change savepoints; the diff is the history rows the change wrote under the batch id" + - "Widget-action refusals the element must show inline are answered as data {op, error, message}, not as an action error (the SPA only toasts errors)" + +key-files: + created: + - ../sm-seo-plugin/mcp.go + - ../sm-seo-plugin/operations.go + - ../sm-seo-plugin/operations_test.go + - ../sm-seo-plugin/mcp_test.go + - ../sm-seo-plugin/batch_test.go + - ../sm-seo-plugin/transfer_test.go + - ../sm-seo-plugin/classes/owner.go + - ../sm-seo-plugin/classes/rules.go + - ../sm-seo-plugin/classes/manage.go + - ../sm-seo-plugin/classes/robots_write.go + - ../sm-seo-plugin/classes/settings_write.go + - ../sm-seo-plugin/classes/issues.go + - ../sm-seo-plugin/classes/batch.go + - ../sm-seo-plugin/classes/undo.go + - ../sm-seo-plugin/classes/csv.go + - ../sm-seo-plugin/classes/transfer.go + - ../sm-seo-plugin/controllers/api/auth.go + - ../sm-seo-plugin/controllers/api/seo.go + - ../sm-seo-plugin/controllers/api/rules.go + - ../sm-seo-plugin/controllers/api/redirects.go + - ../sm-seo-plugin/controllers/api/settings.go + - ../sm-seo-plugin/controllers/api/history.go + - ../sm-seo-plugin/controllers/api/issues.go + - ../sm-seo-plugin/controllers/api/batch.go + - ../sm-seo-plugin/controllers/api/transfer.go + - ../sm-seo-plugin/controllers/transfer.go + - ../sm-seo-plugin/controllers/transfer/config_form.yaml + - ../sm-seo-plugin/models/transfer.go + - ../sm-seo-plugin/models/transfer/fields.yaml + - ../sm-seo-plugin/assets/src/elements/transfer.ts + - scripts/check-phase32.sh + - .planning/phases/32-seo-foundation/32-05-demo-mount.patch + modified: + - ../sm-seo-plugin/routes.go + - ../sm-seo-plugin/classes/service.go + - ../sm-seo-plugin/classes/history.go + - ../sm-seo-plugin/extensions_robots.go + - ../sm-seo-plugin/controllers/api/notfound.go + - ../sm-seo-plugin/controllers/api/robots.go + - ../sm-seo-plugin/controllers/history.go + - ../sm-seo-plugin/controllers/history/config_form.yaml + - ../sm-seo-plugin/controllers/admin_registry.go + - ../sm-seo-plugin/admin_navigation.go + - ../sm-seo-plugin/redirects_test.go + - ../sm-seo-plugin/lang/en/lang.yaml + - ../sm-seo-plugin/lang/pl/lang.yaml + - ../sm-seo-plugin/assets/src/elements/index.ts + - ../sm-seo-plugin/assets/src/elements/labels.ts + - ../sm-seo-plugin/assets/src/elements/styles.css + - ../sm-seo-plugin/assets/dist/seo.js + - ../sm-seo-plugin/assets/dist/seo.css + - ../sm-seo-plugin/README.md + +key-decisions: + - "Orchestrator override: nothing is committed to sm-demo-app in this plan; the two demo tests (TestSEOTabMounted, TestSEOToolsCatalog) travel inside 32-05-demo-mount.patch with the mount, for plan 32-06" + - "Management API errors: refused writes are 422 (redirect_loop with path and message, validation_failed with translated field errors), undo refusals and an address a rule already answers are 409, a stale import 409 through the API" + - "An undo restores through the service (SaveRecord, SaveRule, SaveRedirect, SaveRobots, SaveSettings) so loops are refused and the undo is itself a revision; staleness compares the current value of every field the change wrote with its after value (JSON-normalised)" + - "The import apply token carries the gzip-compressed file, delimiter, dataset and fingerprint, signed with HMAC-SHA256 under a key derived from app.key (a per-process random key when the app has none); apply re-runs the preview and refuses when the fingerprint differs" + - "Import maps rows inside the batch transaction; the fingerprint hashes every row's address and the current state of the object it changes, so a row that was unchanged or new at preview time also makes the apply stale" + - "The exported apostrophe on formula cells is dropped again on import, so an exported file re-imports as all unchanged" + - "The transfer screen's delimiter is the settings column csv_delimiter through a separate models.Transfer view of row 1 (field csv_delimiter, widget _transfer); show/save map to show_settings/update_settings" + - "History undo_batch confirm has no :count (record-action confirms take no parameters, as in 32-04)" + - "The robots.txt admin save now runs classes.SaveRobots too (one write path for admin and API)" + +patterns-established: + - "Every SEO admin operation is listed in AdminOperations with its tool; the parity test derives the required list from controllers, config_list toolbars, record/bulk/widget actions, form extensions and field-type uploads" + +requirements-completed: [SC-2, SC-3, SC-4, D-10, D-11, D-12, D-13, D-14, D-15, D-19, D-90, D-94, D-98] + +coverage: + - id: D1 + description: "Tracer: an MCP-dispatched administrator sets noindex on /pages/a (written_to record, en), resolve answers robots and x_robots_tag noindex, history via mcp with an MCP: author; /landing gets an exact URL rule; force_rule writes a rule; no permission is 403, anonymous 401" + requirement: "D-11" + verification: + - kind: integration + ref: "../sm-seo-plugin/mcp_test.go#TestMCPSetURLSeoTracer" + status: pass + human_judgment: false + - id: D2 + description: "Every admin operation maps to an MCP tool bound to a registered /_seo/api/v1 route (fails when a tool is removed, verified locally); tools are read-only exactly when GET, all backend guard + golem15.seo.manage" + requirement: "D-10" + verification: + - kind: unit + ref: "../sm-seo-plugin/operations_test.go#TestEveryAdminOperationHasTool" + status: pass + - kind: unit + ref: "../sm-seo-plugin/operations_test.go#TestMCPReadToolsAreGET" + status: pass + human_judgment: false + - id: D3 + description: "Management API as an MCP client: effective SEO with sources/owner, rules CRUD, redirects (loop 422 redirect_loop with path, chain warning, origin mcp), 404 log actions (409 on a resolved address), robots.txt and tester, settings refusals, history via mcp" + requirement: "D-13" + verification: + - kind: integration + ref: "../sm-seo-plugin/mcp_test.go#TestManagementAPI" + status: pass + human_judgment: false + - id: D4 + description: "CMS-data issue report: missing description, duplicate title, rule over record, redirect chain with final target, canonical redirected, noindex blocked by robots.txt, each with its fixing tool" + requirement: "D-14" + verification: + - kind: integration + ref: "../sm-seo-plugin/mcp_test.go#TestIssues" + status: pass + human_judgment: false + - id: D5 + description: "Batch: dry run answers diff and conflicts (chain, noindex_published, canonical_redirected) and leaves every row identical; a loop or an invalid value refuses the whole batch with 422 naming the change; apply writes one batch id via mcp" + requirement: "D-12" + verification: + - kind: integration + ref: "../sm-seo-plugin/batch_test.go#TestBatch" + status: pass + human_judgment: false + - id: D6 + description: "Undo of one change (undo revision), already_undone, undo_stale naming the author, undo_loop with the path, undo_gone for a deleted record, batch undo as a whole, History record actions undo/undo_batch with 403 refusals" + requirement: "D-13" + verification: + - kind: integration + ref: "../sm-seo-plugin/batch_test.go#TestUndo" + status: pass + human_judgment: false + - id: D7 + description: "CSV: export with BOM, ; and 'escaped formula cells re-imports unchanged; missing_column, unreadable; a looping row refused and the apply 422; records rows create a URL rule without a record; admin token stale after a data change, forged token refused, fresh apply as one import batch; 60 KB admin and 900 KB MCP caps" + requirement: "D-13" + verification: + - kind: integration + ref: "../sm-seo-plugin/transfer_test.go#TestCSVTransfer" + status: pass + human_judgment: false + - id: D8 + description: "Loops are refused from MCP (tool error with path, also in a batch) and from CSV import (refused row, apply refused), as from the admin" + requirement: "SC-3" + verification: + - kind: integration + ref: "../sm-seo-plugin/redirects_test.go#TestLoopRefused" + status: pass + human_judgment: false + - id: D9 + description: "Demo mount proven in a scratch worktree: Journal posts, categories and tags carry the SEO tab last (posts Edit, Manage, Categories, SEO) and the pier catalog lists every seo_ tool (read grant: GET tools only)" + requirement: "SC-4" + verification: + - kind: integration + ref: "sm-demo-app scratch worktree (32-05-demo-mount.patch)#TestSEOTabMounted, TestSEOToolsCatalog, TestBootAllPlugins" + status: pass + human_judgment: false + - id: D10 + description: "scripts/check-phase32.sh: self-test, go, consumers, docker, admin, docs and marker pass (unmounted allowed); plugins stage blocked only by an unreadable sibling fixture file" + requirement: "D-90" + verification: + - kind: other + ref: "bash scripts/check-phase32.sh --self-test; PHASE32_ALLOW_UNMOUNTED=1 bash scripts/check-phase32.sh --consumers --docs --marker; --docker; --admin; --go" + status: pass + human_judgment: true + rationale: "The plugins stage fails on TestNoTideJournalFixtures in sm-journal-plugin (../fonoteka.go fixture owned by jin, mode 0600, unreadable by the executor), not on phase code; a run as a user that can read it must confirm" + - id: D11 + description: "E6 golem15-seo-transfer looks and behaves as UI-SPEC C13 in the real admin SPA (layout, badges, busy labels, Blob download, inline stale message)" + verification: [] + human_judgment: true + rationale: "Visual and interaction behaviour in the SPA; only a throwaway happy-dom smoke ran (render, refused/nothing gating, stale message kept, pl plural), element unit tests are plan 32-06" + +duration: 45min +completed: 2026-10-27 +status: complete +--- + +# Phase 32 Plan 05: SEO management over MCP, batches, undo and CSV Summary + +**Every SEO admin operation is now a permission-checked `/_seo/api/v1` route with an MCP tool (29 `seo_` tools, a parity test guarding drift). Pages are addressed by URL (record or URL rule). Batches dry-run by rollback and apply all or nothing. Any change or batch can be undone with stale/loop/gone guards. CSV import and export work in a new admin screen (E6, HMAC apply token) and over MCP, and loops are refused on every path. The demo mount is proven in a scratch worktree and stored as a patch, and the phase gate `check-phase32.sh` exists.** + +## Performance + +- **Duration:** 45 min +- **Started:** 2026-10-27T11:03:21Z +- **Completed:** 2026-10-27T11:48:42Z +- **Tasks:** 4 +- **Files:** 49 files changed in ../sm-seo-plugin (7437 insertions), 2 files in this repository + +## Accomplishments + +- **Management API and tools (D-10, D-11, D-13, D-15, P-24, P-25).** + - Routes under `/_seo/api/v1` (bucket `seo-api`). Each resolves the administrator through `cabana.ResolveBackend`, so a pier dispatch runs as the linked admin. Each needs `golem15.seo.manage`. + - `AdminOperations` maps every admin operation to a tool. `TestEveryAdminOperationHasTool` derives the operations from the controllers, configs, actions, form extensions and field-type uploads. It failed when `test_robots` was renamed and when `show_history` was dropped, and was then restored. +- **URL addressing.** `PUT /seo` writes to the record behind the URL in its locale, else to an exact rule. `force_rule` always writes a rule. The answer names `written_to`, `type`, `id`, `locale` and `rule_id`. +- **Issue report (D-14 CMS part).** Missing and duplicate titles and descriptions, rules over records, chains, canonicals that point to noindex or redirected pages, and noindex pages blocked by robots.txt. Each issue names the tool that fixes it. +- **Batches (D-12, P-26).** One transaction with a savepoint per change. The diff is the history rows the batch wrote. A dry run rolls back on purpose; a refused change rolls back everything. +- **Undo (D-13).** `Undo` and `UndoBatch` restore through the service. The undo is itself recorded, and the undone rows are marked. +- **CSV (D-13, P-27).** Export and import of redirects, rules and records by URL. Formula cells are escaped on export and unescaped on import. The `golem15.seo.transfer` screen has element E6; MCP has `export_csv` and `import_csv`. + +## Task Commits + +../sm-seo-plugin, branch main, not pushed: + +1. **Task 1 (tracer):** `cadf6ad` feat: SEO by URL over the management API and MCP (tracer) +2. **Task 2:** `aa0c3bc` feat: management API and an MCP tool per SEO admin operation +3. **Task 3:** + - `ab3b219` feat: batches with dry run, undo, and CSV import and export + - `6f69031` feat: import and export element (E6) +4. **Task 4:** this repository: + - `9247761` test(32-05): check-phase32 gate + - `20d02d7` docs(32-05): sm-demo-app mount patch for golem15.seo + +sm-demo-app: no commit (orchestrator instruction). + +`commits: 2` is measured in this repository with `git rev-list --count f96d7d4..HEAD` before this SUMMARY's commit. The plugin's 4 commits are counted with `git rev-list --count b398161..HEAD`. + +Tracer feedback gate: end-of-phase mode with an automated-only verify. `TestMCPSetURLSeoTracer`, vet and the `-short` tests re-ran green before Task 2. + +## Scratch run (sm-demo-app worktree at 8fcdbdd, mount applied) + +- **Setup:** + - The scratch go.work used absolute paths: the worktree, the live `plugins/golem15/*` checkouts except journal, `../sm-journal-plugin`, `../sm-seo-plugin`, and the framework replace. + - `summer build` printed `✓ Built bin/demo` and regenerated `plugins.gen.go`. +- **Tagged run:** `go test -tags summercms_mcp -run 'TestSEOToolsCatalog|TestSEOTabMounted|TestBootAllPlugins'`: + +``` +--- PASS: TestBootAllPlugins (0.78s) + --- PASS: TestBootAllPlugins/realtime (0.38s) +--- PASS: TestSEOToolsCatalog (1.34s) +--- PASS: TestSEOTabMounted (0.54s) +``` + +- **Untagged run:** the same two passed (`--- PASS: TestSEOTabMounted (1.11s)`). +- **Unmounted:** both tests compile and skip with `NOT MOUNTED golem15.seo`, untagged and tagged. +- **Expected failure:** in the mounted scratch, `go test -short` fails only `TestGoModRequiresTags` (no tagged sm-seo-plugin require yet). This is P-28 and is resolved in plan 32-06. +- **Patch check:** `git apply --check` of the patch on a clean 8fcdbdd passed. +- **Cleanup:** the scratch worktree is removed, and `git worktree list` shows only the main checkout. + +**Demo working tree found at the start:** ` M plugins/golem15/journal`, ` M plugins/golem15/oauthserver` (another session's submodule drift). It is unchanged at the end. + +## TDD Gate Compliance + +Task 3 is `tdd="true"`; `workflow.tdd_mode` is false. +- **RED:** `TestBatch`, `TestUndo`, `TestCSVTransfer` and the `mcp`/`import` subtests of `TestLoopRefused` were written first. All four failed with 404, because the routes did not exist yet. The evidence is in the scratchpad file `32-05-task3-red.txt`. +- **GREEN:** the implementation followed in commit `ab3b219`. +- **No separate RED commit:** a `test(...)` commit would break the plugin's green-at-every-commit rule (CLAUDE.md). + +## Files Created/Modified + +See `key-files` in the frontmatter. + +## Decisions Made + +See `key-decisions` in the frontmatter. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Orchestrator override] Demo tests not committed to sm-demo-app** +- **What the plan said:** commit `seo_mount_test.go` and `seo_mcp_test.go` in sm-demo-app. +- **What changed:** the dispatch said not to commit to sm-demo-app, so both files are in `32-05-demo-mount.patch`. +- **Consequence:** the acceptance criterion `git show --stat HEAD` in sm-demo-app does not apply. + +**2. [Rule 3 - Blocking] TestBootAllPlugins needs the plugin list** +- **Issue:** the demo's test counts its plugins exactly, so mounting the SEO plugin breaks it. +- **Fix:** the patch also adds `golem15.seo` to `wantPlugins` and `/_seo/api/v1` and `/robots.txt` to the routed prefixes. + +**3. [Rule 3 - Blocking] `summer build` ignores GOWORK** +- **Issue:** `summer build` uses the app's own go.work. +- **Fix:** the scratch go.work was copied over the worktree's go.work for the build only, then restored. The patch carries no go.work. + +**4. [Rule 3 - Blocking] surf `Where` applies to the last route only** +- **Issue:** `g.Where("id", …)` after a group of routes refused the boot. +- **Fix:** ids are validated in the handler (`pathID`, 404). + +**5. [Rule 3 - Blocking] History recordActions needs a preview block** +- **Fix:** `preview: {}` in `controllers/history/config_form.yaml`. + +**6. [Rule 1 - Bug] A semicolon in a query is dropped by Go's net/url** +- **Issue:** `delimiter=;` arrives empty. +- **Fix:** the test sends `%3B`; pier encodes query arguments itself. + +**7. [Rule 2 - Missing critical] An import of a dry run with a refused row** +- **Issue:** a dry run with a refused row answered `import_refused`. +- **Fix:** it now answers the preview; only an apply is refused. + +**8. [Plan detail] Field names and files** +- **Transfer screen:** + - The delimiter field is `csv_delimiter`, the column of the settings row (cabana binds scalar fields to model columns), and the widget is `_transfer`. + - The form is `models/transfer/fields.yaml` with a `models.Transfer` view of the row; `models/settings/fields.yaml` is unchanged. +- **Shared field types:** `classes.RedirectFields` and `classes.RuleFields` are shared by the API and the batch. +- **Extra class files:** `classes/manage.go`, `rules.go`, `robots_write.go` and `settings_write.go`. +- **Extra tests:** `TestManagementAPI` and `TestIssues`. + +**9. [Plan detail] Acceptance greps** +- **`Path: "/_seo/api/v1/seo"` in mcp.go:** prints two lines after Task 2 (GET `get_url_seo` and PUT `set_url_seo`); Task 1 printed one. +- **`customElements.define`:** totals 1, because index.ts defines from one list, the 32-03 pattern. That list now holds the 7 elements E1–E7. +- **Guard count:** `grep -c "Guard: *mcpGuard"` is 29. + +**10. [Framework constraint] Message parameters** +- **Undo batch confirm:** has no `:count` (record-action confirms take no parameters). +- **Applied toast:** `transfer.applied` is translated in the action with `Service.ChoiceCtx`. + +**11. [Plan detail] E6 refusals shown inline** +- **Issue:** the SPA only toasts action errors, so the element could not show the stale message itself. +- **Fix:** stale, unreadable, missing column, too large and token refusals are answered as data `{op, error, message}`, so the preview stays on screen with the message. + +--- + +**Total deviations:** 11. +- 1 orchestrator override; +- 4 blocking; +- 1 bug; +- 1 missing critical; +- 4 plan details or framework constraints. + +**Impact on plan:** behaviour is as planned. The demo tests ship in the patch instead of a demo commit. + +## Issues Encountered + +- **`--plugins` gate stage fails on an environmental cause.** `TestNoTideJournalFixtures` in sm-journal-plugin opens `../fonoteka.go/parity/fixtures/mcp/mcp-oauth.yaml`. That file is owned by `jin` with mode 0600, so the executor cannot read it. The plan does not touch it. + - The SEO and Journal checks of the stage pass when run by hand: vet, `-short`, and the required names `TestEveryAdminOperationHasTool`, `TestMCPReadToolsAreGET`, `TestLangKeysComplete`, `TestNormalize`, `TestJournalMCP` and `TestMenuItemTypesPatterns`. +- **Other gate stages:** + - `--self-test`: 21 checks pass. + - `--go`, `--consumers` (`NOT MOUNTED golem15.seo` allowed; the route-drift check passes), `--docs` and `--marker`: pass (2756 added framework lines checked). + - `--docker`: 3 framework, 25 SEO and 5 Journal Postgres tests pass. + - `--admin`: 1563 vitest tests pass, plus the openapi check. + +## Known Stubs + +None. MCP uploads of the social image are not a tool: `set_url_seo` takes `og_image` as a URL. + +## Threat Flags + +| Flag | File | Description | +|------|------|-------------| +| threat_flag: endpoint | ../sm-seo-plugin/controllers/api/*.go | 24 new management routes under /_seo/api/v1. Each resolves a backend principal and requires golem15.seo.manage, with throttle bucket seo-api (T-32-23). | +| threat_flag: token | ../sm-seo-plugin/classes/transfer.go | An HMAC-signed, gzip-compressed import token round-trips through the browser. It is signed under an app.key-derived key, and a forged or edited token is refused (T-32-25). | + +## User Setup Required + +None. The demo's `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` and the robots.txt copy are documented in the patch's DEPLOY.md section for plan 32-06. + +## Next Phase Readiness + +**Plan 32-06:** +- adds the element unit tests and the `additive`/`compat` gate stages; +- tags the framework, Journal and SEO plugin (with Jakub's go-ahead); +- bumps the requires; +- applies `32-05-demo-mount.patch`, adds the submodule and go.work entry, runs `summer build`, and runs `check-phase32.sh --all` without `PHASE32_ALLOW_UNMOUNTED`. + +**Nothing pushed:** `../sm-seo-plugin` main has an origin remote but nothing was pushed. + +## Self-Check: PASSED + +- **Key files:** all 32 exist. +- **Commits present:** + - ../sm-seo-plugin: cadf6ad, aa0c3bc, ab3b219, 6f69031; + - this repository: 9247761, 20d02d7. +- **../sm-seo-plugin:** `go vet ./...`, `go test -short ./...` and `go test -count=1 ./...` (Docker) are green; `npm run typecheck` and `scripts/check-dist.sh` are green. +- **summercms.go:** `go vet ./...` and `go test -short ./...` are green (gate `--go`). + +--- +*Phase: 32-seo-foundation* +*Completed: 2026-10-27*