Apply approved changes (the gate lives here)
Read profile + env file. Poll the channel first if telegram, via
core.approval.process_telegram_decisions(root, token, chat_id) - it
persists the poll offset and tolerates unknown/stale ids without raising.
List approved onpage-fix items. For each:
a. require_approved(path) - this raises on anything not approved. Never
catch that error to proceed; report it and skip.
b. snapshot() the post (title + meta + content if the proposal touches it)
-> save to outcomes/<item-id>-rollback.json in the brain repo.
c. Apply via the CMS adapter (onsite.cms.adapter_for; wordpress or
git-static per cms.type - the git-static branch below replaces
steps b-g):
update_seo_meta / update_post per the proposal body.
(update_rankmath remains as the WordPress adapter's alias for
update_seo_meta.)
d. Verify: get_rendered_head(target_url) - assert the new
title/description appear in the rendered head. On mismatch:
rollback() immediately, then
PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core status <item-path> failed --actor agent, append a signal
"apply-verify failed", and alert. Never leave the item approved.
e. PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core status <item-path> applied --actor agent; write an outcome record
outcomes/<item-id>.md: what changed, when, rollback file, measurement
due dates (+7d, +28d). Because the verify just succeeded, also write
the re-verification window into the record, exactly these keys
(see site-repo-contract.md):
reverify:
due: <UTC now+1h> # first scheduled re-check
until: <UTC now+48h> # window end; drift watch owns the horizon after
hoo-daily re-checks the live values between due and until so an
external revert (a bulk plugin restore, a theme update) surfaces
within hours, not at the next audit.
f. On a successful verify, refresh the drift baseline for this page
(onsite.drift.snapshot_pages on this post id, merged into the
stored onsite.drift.baseline_path entry, then save_baseline) so
the change we just made intentionally is never flagged as drift by
the next hoo-daily run.
g. IndexNow: when site-profile.yaml has indexnow: {enabled: true, key: ...} (additive key), call hoo.indexnow.submit(host, key, [target_url]) after the successful verify and record the returned
status in the outcome record (indexnow: {status: N, submitted: 1}).
A non-200 is recorded, never retried in-run, and never fails the
apply. Skipped in dry-run - nothing changed, nothing to submit.
Commit + push the brain repo if git. Summarize: applied / skipped / failed.
Outcome summary - the approver always hears what happened. Compose ONE
message for the whole run and deliver it through the configured
approval channel (telegram: one sendMessage over the same transport
send_item uses; slack/email: one post/send; in-session: print the
summary; pr-merge: no live channel mid-cycle - append the summary to
the run report and outcome records instead). Never send per-item
messages. Content, one line per item:
- applied and verified: item id + what changed
- partially-applied: item id + the exact human step from the status
note - the approver must know what is waiting on them
- failed / rolled back: item id + the reason
Silent success is a bug: whoever said yes hears the result, whether it
landed, half-landed, or failed. A run that touched nothing sends
nothing.
Git-static sites (cms.type git-static)
The adapter is chosen by cms.type (onsite.cms.adapter_for). When it is
git-static, the write target is a local clone of the site repo
(cms.repo_root) and the pull request is the delivery mechanism. The gate
does not move: step 2a's require_approved runs unchanged, BEFORE any
file is written to any branch. The adapter never runs git; this skill runs
every git/gh command below.
- pr-merge channel only, before the gate: proposals on such sites arrive
as PRs against the brain repo, and the human merge IS the approval.
Detect newly merged proposal PRs (
gh pr view <n> --json state,mergedBy) and record each decision the merge represents:
PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core approve <item-path> --actor <merger> --channel pr-merge. The merge commit is
GitHub's durable record; the CLI entry is the one the gates check. A
closed-unmerged proposal PR is a rejection - record it with core reject the same way. (This mirrors step 1's telegram poll.)
On every other channel the item was approved before this skill ran,
and the site PR below is created only AFTER that approval.
- Gate:
require_approved(path) - unchanged, never caught to proceed.
- Freshen the clone (
git pull on the default branch in cms.repo_root).
snapshot() each target file (the snapshot stores the file's full
text) -> outcomes/<item-id>-rollback.json in the brain repo.
- Apply via the adapter:
update_seo_meta / update_post per the
proposal body - frontmatter and body edits in the working tree only.
- Deliver as a PR - run:
git checkout -b organic-os/<item-id>, git add the changed files,
git commit, git push -u origin organic-os/<item-id>, then
gh pr create --title "<item title>" --body "<the proposal text>".
- Honest status: the adapter's
capabilities() declares needs_human: ["merge-pr", "deploy"], so the item does NOT become applied here. Set
PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core status <item-path> partially-applied --actor agent --note "PR <url> open - a human must merge; the site then deploys itself". The queue shows it
as a PARTIAL row until the merge. No publish happens without a human.
- Merge detection, next run:
gh pr view <n> --json state,mergedBy.
Merged -> core status <item-path> applied --actor <merger>; write
the outcome record (what changed, PR url, who merged and when,
measurement due dates +7d, +28d). On a pr-merge-channel site this
merge doubles as the human sign-off; the merge commit and the outcome
record carry it. Closed unmerged -> a human said no: core status <item-path> failed --actor agent --note "PR closed unmerged" and
delete the branch; nothing reached the default branch, so the
rollback file needs no replay.
- Verify, honestly:
rendered_head_verify is False for this adapter -
there is no rendered head to assert at apply time, and step 2d's
verify does not run. If the profile has cms.deploy_url, fetch the
live page after merge detection and record the result in the outcome
as a best-effort post-deploy check, labeled exactly that - it never
counts as the WordPress-grade verify. IndexNow (step 2g) also moves
to after merge detection: only a merged and deployed change has a
live URL to submit. Skip the drift-baseline refresh (step 2f); the
drift watch is WordPress-only. No reverify keys on this path either:
they attach only to a rendered-head-verified apply (step 2e), and the
best-effort post-deploy check is not that verify.
Dry-run with git-static: the same rules as below - the gate still runs
first, the adapter logs every intended write in dry_run_log, and with
nothing written to the clone there is nothing to commit: no branch, no
PR. The outcome record, marked dry-run, lists the log.
Dry-run mode
When site-profile.yaml has onsite: {dry_run: true} (additive key, schema
stays 1), construct the CMS adapter with dry_run=True
(adapter_for(profile, ..., dry_run=True)) and run
the full flow above unchanged - require_approved is still enforced
BEFORE the dry-run write, so a dry run rehearses the real path, gate
included, not a shortcut around it. Nothing reaches the site: every
mutating call lands in the adapter's dry_run_log instead of the session,
and reads (snapshot, get_rendered_head) behave normally. Skip step 2d's
verify assertion (a
write that never happened cannot appear in the rendered head) and do NOT
set the item to applied - it stays approved so a real apply can follow.
Write the outcome record marked dry-run: true, listing every entry from
the adapter's dry_run_log - each write that would have happened, with method,
post id, and fields. Skip the drift-baseline refresh (2f) and the
reverify keys; the page did not change, so there is nothing to
re-check.
Redirect fixes (action type: redirect)
Some approved onpage-fix proposals prescribe a redirect - a moved target
behind broken internal links (skills/onsite-audit's link-health
dimension), or a cannibalization consolidation (skills/hoo-weekly). A
plain link REWRITE is not this action type: it edits the source page's
content and rides the normal update_post path above, gate, snapshot,
verify and all.
Redirects are a CMS capability, not a universal write. Consult the
adapter's capabilities()['redirects'] mode FIRST and never fake a
write path the adapter does not declare:
needs-plugin (the WordPress adapter): core WordPress has no redirect
REST surface. Probe the known plugin surfaces at run time with the
connected role - Rank Math's redirections module, the Redirection
plugin's REST route. A surface is writable: create the 301, record its
id in the outcome record (rollback for a redirect is deleting it),
then verify by fetching the old URL and asserting a 301 status
pointing at the new target. A failed verify deletes the redirect and
marks the item failed, per the hard rules. No writable surface (module
absent, or it needs an admin role the connected user lacks): set the
item partially-applied with the exact manual step, e.g. "add a 301
from /old-path to /new-path in the SEO plugin's redirections screen
(Rank Math -> Redirections)". Never faked as done.
config-file (the git-static adapter): write the platform's redirect
file - the profile's additive cms.redirect_file key names it
(_redirects, netlify.toml, or vercel.json; see
site-repo-contract.md). The write is ADDITIVE: append or merge the one
new rule, never a rewrite of existing rules. Everything else follows
the git-static flow above unchanged: gate first, snapshot() of the
redirect file, branch, PR, partially-applied until the human merge,
best-effort post-deploy check of the old URL when cms.deploy_url is
set. No redirect_file key in the profile: partially-applied naming
the step ("set cms.redirect_file in site-profile.yaml, or add the
redirect to the platform config by hand").
native: reserved for an adapter whose backend writes redirects
first-class through the adapter itself; no shipped adapter declares it
today.
Image and alt-text fixes (action type: image-fix)
Approved image-fix proposals carry per-image items (see the image-fix
section in skills/onsite-propose): missing alt text with the proposed
alt, and missing in-content images referencing a ce-image brief. The
gate is unchanged - require_approved runs before any write, per item.
Consult the adapter's capabilities()['media_alt'] mode FIRST, the same
honesty rule as redirects:
True (the WordPress adapter): before writing, record each target
image's current alt (get_media(post_id)) in the item's rollback
file - rollback for an alt fix is rewriting the old value (empty
included). Then update_media_alt(media_id, alt_text) per item.
An image get_media reports with media_id: None has no attachment
to write to - its alt is hard-coded in the post content, so fix it
via update_post on the content (snapshot and verify as usual); if
the markup is theme- or builder-owned and update_post cannot reach
it, the item ends partially-applied naming the step ("set the alt on
in the theme/page builder").
"in-content" (the git-static adapter): update_media_alt rewrites
the alt inside the content file - media_id <post-ref>::<src> for a
body image, <post-ref>::frontmatter for the featured image's alt:
field. The change rides the normal git-static flow above unchanged:
gate first, snapshot() of the file, branch, PR, partially-applied
until the human merge.
- Missing in-content image items PLACE, never generate: apply inserts a
reference to an image file that already exists (the ce-image output
named in the proposal, or a human-produced asset at that path), with
the proposal's alt text. Nothing in the apply path generates an
image. No file at the named path -> partially-applied with the step
named ("produce the image per -image-brief.md, then re-run
apply").
Verify step reads the result back: after the writes, get_media on the
target must show the new alt (and the placed image, for insertion
items); on WordPress also confirm via the rendered content where
available. A mismatch rolls back from the recorded prior values and
marks the item failed, per the hard rules. Anything the adapter cannot
do ends partially-applied with the named human step - never faked as
done.
Partial application
When some changes in a proposal succeed and others hit a permission or
capability wall (a step needs a WordPress role the connected user does not
have), do NOT pick between "applied" and "failed" - both would lie. Set the
honest state via the contract CLI, with a note naming exactly what a human
must finish:
PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core status <item-path> \
partially-applied --actor agent --note "<exactly what a human must finish>"
The outcome record lists done vs pending: every change that landed (with
its verify result) and every step still waiting on a human, each named
precisely. Once the human finishes the pending steps, the item moves
partially-applied -> applied via the CLI; if the partial work is rolled
back instead, partially-applied -> failed.
HARD RULES: no snapshot -> no write. Verify after every write. A failed verify
means rollback, never retry-and-hope. Never edit brain frontmatter directly.
The contract CLI is the only write path for status and approvals.
1---2name: onsite-apply3description: Use to execute APPROVED on-page proposals - "apply the approved fixes", /organic-os:apply, or a routine's apply step. Refuses anything not approved.4---56# Apply approved changes (the gate lives here)781. Read profile + env file. Poll the channel first if telegram, via9 `core.approval.process_telegram_decisions(root, token, chat_id)` - it10 persists the poll offset and tolerates unknown/stale ids without raising.112. List approved onpage-fix items. For each:12 a. `require_approved(path)` - this raises on anything not approved. Never13 catch that error to proceed; report it and skip.14 b. `snapshot()` the post (title + meta + content if the proposal touches it)15 -> save to `outcomes/<item-id>-rollback.json` in the brain repo.16 c. Apply via the CMS adapter (`onsite.cms.adapter_for`; wordpress or17 git-static per `cms.type` - the git-static branch below replaces18 steps b-g):19 `update_seo_meta` / `update_post` per the proposal body.20 (`update_rankmath` remains as the WordPress adapter's alias for21 `update_seo_meta`.)22 d. Verify: `get_rendered_head(target_url)` - assert the new23 title/description appear in the rendered head. On mismatch:24 `rollback()` immediately, then25 `PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core status26 <item-path> failed --actor agent`, append a signal27 "apply-verify failed", and alert. Never leave the item approved.28 e. `PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core status29 <item-path> applied --actor agent`; write an outcome record30 `outcomes/<item-id>.md`: what changed, when, rollback file, measurement31 due dates (+7d, +28d). Because the verify just succeeded, also write32 the re-verification window into the record, exactly these keys33 (see site-repo-contract.md):3435 ```yaml36 reverify:37 due: <UTC now+1h> # first scheduled re-check38 until: <UTC now+48h> # window end; drift watch owns the horizon after39 ```4041 hoo-daily re-checks the live values between `due` and `until` so an42 external revert (a bulk plugin restore, a theme update) surfaces43 within hours, not at the next audit.44 f. On a successful verify, refresh the drift baseline for this page45 (`onsite.drift.snapshot_pages` on this post id, merged into the46 stored `onsite.drift.baseline_path` entry, then `save_baseline`) so47 the change we just made intentionally is never flagged as drift by48 the next `hoo-daily` run.49 g. IndexNow: when site-profile.yaml has `indexnow: {enabled: true,50 key: ...}` (additive key), call `hoo.indexnow.submit(host, key,51 [target_url])` after the successful verify and record the returned52 status in the outcome record (`indexnow: {status: N, submitted: 1}`).53 A non-200 is recorded, never retried in-run, and never fails the54 apply. Skipped in dry-run - nothing changed, nothing to submit.553. Commit + push the brain repo if git. Summarize: applied / skipped / failed.564. Outcome summary - the approver always hears what happened. Compose ONE57 message for the whole run and deliver it through the configured58 approval channel (telegram: one `sendMessage` over the same transport59 `send_item` uses; slack/email: one post/send; in-session: print the60 summary; pr-merge: no live channel mid-cycle - append the summary to61 the run report and outcome records instead). Never send per-item62 messages. Content, one line per item:63 - applied and verified: item id + what changed64 - partially-applied: item id + the exact human step from the status65 note - the approver must know what is waiting on them66 - failed / rolled back: item id + the reason67 Silent success is a bug: whoever said yes hears the result, whether it68 landed, half-landed, or failed. A run that touched nothing sends69 nothing.7071## Git-static sites (cms.type git-static)7273The adapter is chosen by `cms.type` (`onsite.cms.adapter_for`). When it is74git-static, the write target is a local clone of the site repo75(`cms.repo_root`) and the pull request is the delivery mechanism. The gate76does not move: step 2a's `require_approved` runs unchanged, BEFORE any77file is written to any branch. The adapter never runs git; this skill runs78every git/gh command below.79800. pr-merge channel only, before the gate: proposals on such sites arrive81 as PRs against the brain repo, and the human merge IS the approval.82 Detect newly merged proposal PRs (`gh pr view <n> --json83 state,mergedBy`) and record each decision the merge represents:84 `PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core approve85 <item-path> --actor <merger> --channel pr-merge`. The merge commit is86 GitHub's durable record; the CLI entry is the one the gates check. A87 closed-unmerged proposal PR is a rejection - record it with `core88 reject` the same way. (This mirrors step 1's telegram poll.)89 On every other channel the item was approved before this skill ran,90 and the site PR below is created only AFTER that approval.911. Gate: `require_approved(path)` - unchanged, never caught to proceed.922. Freshen the clone (`git pull` on the default branch in `cms.repo_root`).933. `snapshot()` each target file (the snapshot stores the file's full94 text) -> `outcomes/<item-id>-rollback.json` in the brain repo.954. Apply via the adapter: `update_seo_meta` / `update_post` per the96 proposal body - frontmatter and body edits in the working tree only.975. Deliver as a PR - run:98 `git checkout -b organic-os/<item-id>`, `git add` the changed files,99 `git commit`, `git push -u origin organic-os/<item-id>`, then100 `gh pr create --title "<item title>" --body "<the proposal text>"`.1016. Honest status: the adapter's `capabilities()` declares `needs_human:102 ["merge-pr", "deploy"]`, so the item does NOT become applied here. Set103 `PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core status104 <item-path> partially-applied --actor agent --note "PR <url> open - a105 human must merge; the site then deploys itself"`. The queue shows it106 as a PARTIAL row until the merge. No publish happens without a human.1077. Merge detection, next run: `gh pr view <n> --json state,mergedBy`.108 Merged -> `core status <item-path> applied --actor <merger>`; write109 the outcome record (what changed, PR url, who merged and when,110 measurement due dates +7d, +28d). On a pr-merge-channel site this111 merge doubles as the human sign-off; the merge commit and the outcome112 record carry it. Closed unmerged -> a human said no: `core status113 <item-path> failed --actor agent --note "PR closed unmerged"` and114 delete the branch; nothing reached the default branch, so the115 rollback file needs no replay.1168. Verify, honestly: `rendered_head_verify` is False for this adapter -117 there is no rendered head to assert at apply time, and step 2d's118 verify does not run. If the profile has `cms.deploy_url`, fetch the119 live page after merge detection and record the result in the outcome120 as a best-effort post-deploy check, labeled exactly that - it never121 counts as the WordPress-grade verify. IndexNow (step 2g) also moves122 to after merge detection: only a merged and deployed change has a123 live URL to submit. Skip the drift-baseline refresh (step 2f); the124 drift watch is WordPress-only. No `reverify` keys on this path either:125 they attach only to a rendered-head-verified apply (step 2e), and the126 best-effort post-deploy check is not that verify.127128Dry-run with git-static: the same rules as below - the gate still runs129first, the adapter logs every intended write in `dry_run_log`, and with130nothing written to the clone there is nothing to commit: no branch, no131PR. The outcome record, marked dry-run, lists the log.132133## Dry-run mode134135When site-profile.yaml has `onsite: {dry_run: true}` (additive key, schema136stays 1), construct the CMS adapter with `dry_run=True`137(`adapter_for(profile, ..., dry_run=True)`) and run138the full flow above unchanged - `require_approved` is still enforced139BEFORE the dry-run write, so a dry run rehearses the real path, gate140included, not a shortcut around it. Nothing reaches the site: every141mutating call lands in the adapter's `dry_run_log` instead of the session,142and reads (snapshot, get_rendered_head) behave normally. Skip step 2d's143verify assertion (a144write that never happened cannot appear in the rendered head) and do NOT145set the item to `applied` - it stays `approved` so a real apply can follow.146Write the outcome record marked `dry-run: true`, listing every entry from147the adapter's `dry_run_log` - each write that would have happened, with method,148post id, and fields. Skip the drift-baseline refresh (2f) and the149`reverify` keys; the page did not change, so there is nothing to150re-check.151152## Redirect fixes (action type: redirect)153154Some approved onpage-fix proposals prescribe a redirect - a moved target155behind broken internal links (skills/onsite-audit's link-health156dimension), or a cannibalization consolidation (skills/hoo-weekly). A157plain link REWRITE is not this action type: it edits the source page's158content and rides the normal `update_post` path above, gate, snapshot,159verify and all.160161Redirects are a CMS capability, not a universal write. Consult the162adapter's `capabilities()['redirects']` mode FIRST and never fake a163write path the adapter does not declare:164165- `needs-plugin` (the WordPress adapter): core WordPress has no redirect166 REST surface. Probe the known plugin surfaces at run time with the167 connected role - Rank Math's redirections module, the Redirection168 plugin's REST route. A surface is writable: create the 301, record its169 id in the outcome record (rollback for a redirect is deleting it),170 then verify by fetching the old URL and asserting a 301 status171 pointing at the new target. A failed verify deletes the redirect and172 marks the item failed, per the hard rules. No writable surface (module173 absent, or it needs an admin role the connected user lacks): set the174 item partially-applied with the exact manual step, e.g. "add a 301175 from /old-path to /new-path in the SEO plugin's redirections screen176 (Rank Math -> Redirections)". Never faked as done.177- `config-file` (the git-static adapter): write the platform's redirect178 file - the profile's additive `cms.redirect_file` key names it179 (`_redirects`, `netlify.toml`, or `vercel.json`; see180 site-repo-contract.md). The write is ADDITIVE: append or merge the one181 new rule, never a rewrite of existing rules. Everything else follows182 the git-static flow above unchanged: gate first, `snapshot()` of the183 redirect file, branch, PR, partially-applied until the human merge,184 best-effort post-deploy check of the old URL when `cms.deploy_url` is185 set. No `redirect_file` key in the profile: partially-applied naming186 the step ("set cms.redirect_file in site-profile.yaml, or add the187 redirect to the platform config by hand").188- `native`: reserved for an adapter whose backend writes redirects189 first-class through the adapter itself; no shipped adapter declares it190 today.191192## Image and alt-text fixes (action type: image-fix)193194Approved image-fix proposals carry per-image items (see the image-fix195section in skills/onsite-propose): missing alt text with the proposed196alt, and missing in-content images referencing a ce-image brief. The197gate is unchanged - `require_approved` runs before any write, per item.198Consult the adapter's `capabilities()['media_alt']` mode FIRST, the same199honesty rule as redirects:200201- `True` (the WordPress adapter): before writing, record each target202 image's current alt (`get_media(post_id)`) in the item's rollback203 file - rollback for an alt fix is rewriting the old value (empty204 included). Then `update_media_alt(media_id, alt_text)` per item.205 An image `get_media` reports with `media_id: None` has no attachment206 to write to - its alt is hard-coded in the post content, so fix it207 via `update_post` on the content (snapshot and verify as usual); if208 the markup is theme- or builder-owned and `update_post` cannot reach209 it, the item ends partially-applied naming the step ("set the alt on210 <src> in the theme/page builder").211- `"in-content"` (the git-static adapter): `update_media_alt` rewrites212 the alt inside the content file - media_id `<post-ref>::<src>` for a213 body image, `<post-ref>::frontmatter` for the featured image's `alt:`214 field. The change rides the normal git-static flow above unchanged:215 gate first, `snapshot()` of the file, branch, PR, partially-applied216 until the human merge.217- Missing in-content image items PLACE, never generate: apply inserts a218 reference to an image file that already exists (the ce-image output219 named in the proposal, or a human-produced asset at that path), with220 the proposal's alt text. Nothing in the apply path generates an221 image. No file at the named path -> partially-applied with the step222 named ("produce the image per <slug>-image-brief.md, then re-run223 apply").224225Verify step reads the result back: after the writes, `get_media` on the226target must show the new alt (and the placed image, for insertion227items); on WordPress also confirm via the rendered content where228available. A mismatch rolls back from the recorded prior values and229marks the item failed, per the hard rules. Anything the adapter cannot230do ends partially-applied with the named human step - never faked as231done.232233## Partial application234235When some changes in a proposal succeed and others hit a permission or236capability wall (a step needs a WordPress role the connected user does not237have), do NOT pick between "applied" and "failed" - both would lie. Set the238honest state via the contract CLI, with a note naming exactly what a human239must finish:240241```242PYTHONPATH="$CLAUDE_PLUGIN_ROOT/lib" python3 -m core status <item-path> \243 partially-applied --actor agent --note "<exactly what a human must finish>"244```245246The outcome record lists done vs pending: every change that landed (with247its verify result) and every step still waiting on a human, each named248precisely. Once the human finishes the pending steps, the item moves249partially-applied -> applied via the CLI; if the partial work is rolled250back instead, partially-applied -> failed.251252HARD RULES: no snapshot -> no write. Verify after every write. A failed verify253means rollback, never retry-and-hope. Never edit brain frontmatter directly.254The contract CLI is the only write path for status and approvals.