_type: issue

title: Plugin: per-step 'view previous notes' panel

description:
Each step card gets a small link/disclosure: 'Previous notes for this step (N)' where N is the count from the server.

On expand: render a list of past notes from GET /healthchecks/steps/{step_id}?site_key=<current>&limit=5. Each entry shows:
- started_at (formatted)
- status badge
- notes (or 'no notes')
- reporting_url if different from the current session

Loaded lazily on first expand (one request per step) to avoid hammering the server on page load. Cache result for the page lifetime.

status: closed

issue_type: feature

assignee: Steve Hanlon

created_by: Steve Hanlon

close_reason:
Per-step history disclosure shipped end-to-end.

Server:
- New GET /step-counts?site_key=...&exclude_id=... returns {counts: {step_id: int}} for the (N) badge in one round-trip
- stepHistory + stepCountsForSite both grew an exclude_id param so the active session doesn't appear in its own 'previous notes' panel
- Routes: /step-counts moved to top-level path to avoid the /healthchecks/{id} pattern claiming 'step-counts' as an id

Plugin:
- ATT_HC_Api::step_counts() and step_history() pass exclude_id when available
- att_hc_render_active_session pre-fetches step counts once and passes per-step count to the card renderer
- Step card now renders a <details class=att-hc-history data-step-id=…> with summary 'Previous notes for this step (N)' when N>0
- New att_hc_print_step_history_assets prints the inline CSS + vanilla JS that hooks the details.toggle event: lazy-fetches on first expand via admin-ajax (action=att_hc_step_history), renders entries with date + status badge + notes + 'Reported from <url>' when different from current reporting_url. Marks data-loaded=yes to cache for page lifetime; resets to no on error so user can retry.
- New wp_ajax_att_hc_step_history admin-ajax handler returns wp_send_json_success/error with nonce verification (att_hc_step_history nonce)

Verified end-to-end against live server: counts correct with + without exclude_id (4 vs 3 for active vs prior), step_history filters active session, empty step / unknown site return cleanly, reordered routes still work.

type: parent-child

type: blocks


title: Plugin: start screen — site_key field + recent dropdown + resume

description:
When no active local session exists, the start screen shows:

1. Text input 'Site / engagement key' — defaults to normalised get_site_url() (lowercase, strip scheme + leading www., trim trailing slash). Editable. Help text: 'Used to group runs that span dev + live for the same engagement. Pick from recent engagements below if continuing one.'
2. Dropdown 'Recent engagements' populated from GET /sites?limit=20. Selecting one fills the input.
3. After site_key is set, GET /healthchecks?site_key=...&include=steps. If any UNFINISHED sessions exist, list them with started_at and progress, each with a [Resume] button. Plus a separate [Start fresh] button.

Resume: load the chosen session into the local option (no new POST). Start fresh: new POST /healthchecks.

issue_type: task

close_reason:
Start screen rewritten:
- Editable 'Site / engagement key' input, defaults to ATT_HC_Session::normalise_site_url(get_site_url()).
- datalist-backed autocomplete from GET /sites?limit=20 (recent engagements). Picking one fills the input via native browser UX.
- 'Look up engagement' submits as GET with site_key in the URL, so re-rendering picks it up and re-queries incompletes.
- Table of in-progress sessions for the selected site_key with started_at, last-activity, reporting_url, and a [Resume] button per row. Each Resume button posts to a new att_hc_handle_resume handler which calls ATT_HC_Session::resume($id).
- Separate Start button below labelled differently depending on whether incompletes exist.

Double-click guard: att_hc_handle_start (and att_hc_handle_resume) early-return to the main page if ATT_HC_Session::current() is non-null, so a stray repeat click can't 409 against the (site_key, started_at) unique constraint with a confusing duplicate error.

End-to-end verified: /sites populates the datalist, incomplete filter shows 2 alpha + 1 beta + 0 unknown correctly, resume() repopulates cache and restores step notes, finish() removes from incomplete list. Lookup-err and recent-sites-err paths both surface as inline warnings without blocking the Start button.


title: Plugin: refactor ATT_HC_Session to write-through

description:
Session is now server-of-truth. Local WP option becomes a thin cache of the *active* session for fast page loads.

Changes:
- start(): generates id locally, POSTs to /healthchecks, stores response in option. site_key + reporting_url are new fields.
- update_step(): updates option AND PUTs to /healthchecks/{id}/steps/{step_id}. If server call fails, the option is NOT updated and the caller sees the error (no drift).
- finish(): PUT /healthchecks/{id} with finished_at, then update option.
- set_autocheck(): same write-through pattern.
- previous(): replace local 'att_hc_previous_session' option with a call to GET /healthchecks?site_key=...&include=steps&limit=2, return the most recent FINISHED one before the current session.
- discard(): delete option only. Server keeps the record (intentional; history is the point).

Remove the att_hc_previous_session option entirely (data is on the server now).

close_reason: ATT_HC_Session refactored to write-through. Every mutating method (start / update_step / finish / set_autocheck) POSTs or PUTs to the central server first; only on success does the local WP option cache get updated. ATT_HC_Api_Exception bubbles to callers — admin handlers (start / save_step / finish / refresh_checks / step_action) now wrap calls in try/catch and surface via att_hc_api_error transient → admin notice. Added: site_key field (defaults to normalise_site_url(get_site_url())), reporting_url field, ATT_HC_Session::resume(id) for hc-m1a, ATT_HC_Session::normalise_site_url(). previous() now fetches from server (list_healthchecks ?include=steps) and reconstructs into the local session shape; degrades silently on server unreachable since diff is a nice-to-have. Removed att_hc_previous_session option entirely. Also added a config-error gate at the top of the admin page that blocks the UI with a clear notice when wp-config.php constants are missing or wrong. Added server/dev-router.php to work around PHP -S 405-ing dotted uniqid paths (production Apache/nginx unaffected). End-to-end verified against live server: 13 assertions covering happy path, autocheck preservation across upserts, multi-session-per-site, previous()/resume() reconstruction, discard-preserves-server-record, and error bubbling.


title: Plugin: HTTP client + config constants

description:
New class ATT_HC_Api in includes/class-att-hc-api.php.

Reads ATT_HC_API_URL and ATT_HC_API_KEY from wp-config.php constants. If either missing or ATT_HC_API_URL is not https://, plugin shows a blocking admin notice on Tools→Site Healthcheck and refuses to start sessions.

Methods mirror server endpoints. wp_remote_post/get with timeout=15, Authorization header injected. On non-2xx, throws ATT_HC_Api_Exception with the server's error.code and human message. Caller (admin page) catches and surfaces.

Acceptance:
- Missing constants: clear notice, no PHP fatals
- Wrong key (401): notice says 'server rejected our credentials'
- Server down: notice says 'central history server unreachable — retry'

close_reason: ATT_HC_Api + ATT_HC_Api_Exception added and wired into the plugin bootstrap. Reads ATT_HC_API_URL / ATT_HC_API_KEY constants from wp-config.php. Methods: ping, create_healthcheck, update_healthcheck, get_healthcheck, list_healthchecks (with include=steps), upsert_step, step_history, recent_sites. Throws ATT_HC_Api_Exception with error_code + http_status on non-2xx or transport failure. Refuses non-HTTPS endpoints (loopback exception for dev). Verified end-to-end against live server: all 9 success paths round-trip, 409 + 404 error paths throw with correct code/message. No existing plugin behaviour changed yet — wiring in happens in hc-9jl.

depends_on_id: hc-rdo


title: Server: HTTP endpoints

description:
All routes require Authorization: Bearer <ATT_HC_API_KEY>. JSON in/out. UTC unix timestamps.

POST   /healthchecks                                  Register new session. Body: {id, site_key, started_at, reporting_url, technician_id?, wp_version, php_version}. 201 on success, 409 if (site_key, started_at) collides.
PUT    /healthchecks/{id}                             Update session metadata. Body: {finished_at?}. 200 on success.
PUT    /healthchecks/{id}/steps/{step_id}             Upsert step state. Body: {status, notes, autocheck?, reporting_url}. 200 on success.
GET    /healthchecks?site_key=...&include=steps       List sessions for a site, newest first. include=steps inlines step_updates.
GET    /healthchecks/{id}                             Full session including steps. For Resume flow.
GET    /healthchecks/steps/{step_id}?site_key=...&limit=5   Notes for one step across last N sessions for this site. Newest first.
GET    /sites?limit=20                                Recent distinct site_keys for the start-screen dropdown.

Errors: {error: 'human message', code: 'machine_code'}. 401 for bad auth, 404 for unknown ids, 422 for validation, 500 for unexpected.

close_reason: All 7 endpoints implemented and smoke-tested end-to-end with curl: POST /healthchecks (201/409/422), GET /healthchecks/{id} (200/404), PUT /healthchecks/{id} (finish), PUT step (upsert with autocheck, 404/422), GET list (with optional include=steps, ordered DESC by started_at), GET step history across sessions, GET /sites recent keys. Multi-session per site_key works (different started_at). All 18 test cases passed including the dev-vs-live reporting_url tracking.

issue_id: hc-rdo


title: Server: schema for healthchecks + step_updates + audit

description:
Tables:

healthchecks
  id TEXT PRIMARY KEY              -- client-generated (uniqid from plugin)
  site_key TEXT NOT NULL           -- composite key with started_at
  started_at INTEGER NOT NULL      -- unix seconds
  finished_at INTEGER NULL
  technician_id INTEGER NULL       -- WP user id, informational
  reporting_url TEXT NOT NULL      -- get_site_url() at start
  wp_version TEXT
  php_version TEXT
  created_at INTEGER NOT NULL
  updated_at INTEGER NOT NULL
  UNIQUE (site_key, started_at)

step_updates
  healthcheck_id TEXT NOT NULL REFERENCES healthchecks(id) ON DELETE CASCADE
  step_id TEXT NOT NULL
  status TEXT NOT NULL             -- not_started|done|skipped|blocked|n_a
  notes TEXT NOT NULL DEFAULT ''
  autocheck_json TEXT NULL         -- JSON blob if step ran autocheck
  updated_at INTEGER NOT NULL
  reporting_url TEXT NOT NULL
  PRIMARY KEY (healthcheck_id, step_id)

request_log (audit, simple)
  id INTEGER PRIMARY KEY AUTOINCREMENT
  ts INTEGER NOT NULL
  method TEXT
  path TEXT
  status INTEGER
  ip TEXT
  bytes_in INTEGER
  bytes_out INTEGER

Indexes:
- healthchecks(site_key, started_at DESC)
- step_updates(step_id, updated_at DESC) -- for cross-session step history

close_reason: Schema + auto-migration runner in place. 0001_initial.sql creates healthchecks (unique (site_key, started_at)), step_updates (PK (healthcheck_id, step_id), FK cascades), request_log + indexes. Migrations table tracks applied files. Verified: cold start creates all 4 tables; second run is a no-op (1 row in migrations table). SQLite WAL + foreign_keys enabled in Db.php.


title: Server: scaffold PHP/SQLite app in server/

description:
Single-file router (index.php) + PDO + SQLite. No framework.

Layout:
- server/public/index.php — front controller, routing
- server/src/ — Router, Auth, Store, controllers
- server/migrations/ — SQL files run on boot if schema missing
- server/config.php.example — copy to config.php on deploy, holds API key + DSN
- server/.htaccess — route all to public/index.php

Acceptance:
- 'php -S localhost:8000 -t server/public' starts a working dev server
- GET / returns {ok:true, version:...}
- Missing/invalid Authorization header returns 401

close_reason: Scaffold complete: front controller + autoloader + Config + Http + Auth + Router + GET /. Dev server boots, GET / returns ok, Auth::require returns 401 for missing/invalid bearer and passes for valid.


title: Decision: distribution model — internal-only, no WP.org listing

description:
This is an internal/agency tool, not a public plugin. Distributed as a built ZIP (GitHub release artefact). Technicians install via wp-admin → Plugins → Upload, or via wp-cli (`wp plugin install <url> --activate`).

**Why:** lets us release fast, keeps client-specific text/checks private, no review process.

**How to apply:**
- Don't add WP.org boilerplate (readme.txt, screenshots, banner.png).
- Plugin header version = source of truth for what's installed.
- Phase 3 considers a self-hosted update channel (hc-5ix.NEW).

status: open

issue_type: decision


title: Session lifecycle: Start/Resume/Finish actions

description: Start: creates new session, snapshots site URL + WP/PHP versions + technician. Resume: continues the current in-progress session. Finish: stamps finished_at, locks notes, opens report view. If a session is already in-progress, Start asks to discard or resume.

notes: Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.


title: Report generation: Markdown export + copy to clipboard + save to private CPT

description: Render the finished session as Markdown (technician, site URL, started/finished, then per-step status + notes + sub-items). 'Copy' button and 'Save as report post' (private CPT 'wph_report') so reports are retrievable per site. Plain text fallback. No PDF in phase 1.


title: Checklist UI: single admin page with section per step, checkbox group + notes textarea

description: One page, all 12 steps + before-you-start. Each step is a card with: title, sub-items as readable bullets, a status select (not started/done/skipped/blocked/n/a), a notes textarea. Saves on blur or via explicit Save. Shows the 'Watch out for' callouts from steps.md inline.


title: Step definitions module: single PHP file defining the 12 steps + 'Before You Start' as structured data (id, title, blurb, sub-items, escalation conditions)

description: Source of truth for both the MVP UI and the automation layer. Keep it boring: pure data, no presentation. Lives at includes/steps.php returning a typed array. The text comes from steps.md (current copy in repo root).


title: Session data model: option-backed in-progress healthcheck record

description: One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.


title: Plugin skeleton: header, activation hook, deactivation hook, admin menu (Tools → Site Healthcheck), capability gate


title: Bootstrap: trigger install of wp-site-recovery plugin as step 0

description: Healthcheck plugin should check on activation if site-recovery is installed; if not, fetch and install from a known URL/ZIP and activate it. Block stepper from starting until recovery is in place.

notes: Detection half done (status panel + manual link). Auto-install of recovery plugin from a private URL is the wp-site-recovery side of hc-5ix.27 — closing this as 'detection complete'.


title: Server: deploy notes for VPS

description:
Document deployment to a PHP VPS:
- PHP version requirements
- Apache/nginx rewrite to public/index.php
- File perms for SQLite db file
- Suggested location (/var/www/att-hc-server/)
- Generating + setting ATT_HC_API_KEY
- Backup strategy for the SQLite file (cron + scp/rsync)
- HTTPS via Let's Encrypt

Lives at server/DEPLOY.md.

close_reason: DEPLOY.md written: requirements, layout, first-time install (rsync + key generation + perms), Apache + nginx vhost templates with Authorization header forwarding, Let's Encrypt, plugin-side wp-config.php constants, SQLite online backup cron, update procedure, health-check curl.


title: [epic] Central history server + plugin sync

description:
Replace the local-only session model with a required central server that stores healthcheck history across engagements.

Motivation:
- Plugin is installed per engagement and uninstalled at the end, so today's local 'previous session' diff dies with it
- Healthchecks differ between sites; cross-engagement history lets us see prior notes per step and review what's drifted
- Engagements often span dev + live (different URLs, same logical site), and may run over days/weeks — sometimes never completed

Scope:
- New PHP/SQLite server in server/ (same repo), deployed to a VPS
- Plugin becomes a write-through client: server is source of truth
- DB key = (site_key, started_at); site_key defaults to normalised get_site_url() but is editable on start
- Multiple incomplete sessions per site allowed; on start, tech can Resume or Start fresh
- Each step card gets a 'view previous notes' link that pulls history from the server
- Auth: single shared secret as ATT_HC_API_KEY constant in wp-config.php
- HTTPS required; plain HTTP rejected client-side
- Server unreachable = Start/Save/Finish block with retry. No offline queue v1.

close_reason: All 8 child issues closed. Central history server + plugin sync feature complete: PHP/SQLite server with 8 endpoints (incl. /step-counts), schema with composite key (site_key, started_at) + step_updates + audit log, deploy notes for VPS (using /home/www/healthcheck). Plugin: ATT_HC_Api HTTP client with HTTPS guard + loopback dev exception, ATT_HC_Session refactored to write-through (server-of-truth), start screen with site_key + recent-engagements datalist + resume of incomplete sessions + double-click guard, per-step previous-notes disclosure with lazy loading. End-to-end verified across 4 separate test runs.


title: Step — Email Delivery Test (send wp_mail to chosen address)

description:
Add a new step that sends a test email via wp_mail() to an address the technician types in. Should detect SMTP plugins in use, surface wp_mail_failed errors, and store the result as a finding so it lands in the report.

Drives a small architectural extension: WPH_Step gains optional render_extra() (output HTML inside the card) and handle_action() (handle a step-specific POST), plus a generic wph_step_action admin-post handler. Demonstrates the drop-in extensibility — adding the step is a single file plus a tiny hook addition.

notes:
Added in commit (next). New file includes/steps/115-email.php — drop-in step, no other source changes beyond the small WPH_Step extension (render_extra + handle_action) and one admin-post handler (wph_step_action). Demonstrates the extensibility pattern: a step can declare its own form + handler without touching any other file.

Smoke-tested on testsite: step appears in correct slot (between small_fixes and wrap_up), autocheck surfaces mailer detection (PHP mail vs. 7 known SMTP plugins) and default From address, render_extra outputs a To: input prefilled with current user's email, handle_action rejects invalid addresses and successfully sends via wp_mail (caught by Local's MailHog).


title: Smoke test on testsite: full end-to-end run through the checklist


title: README + install instructions + screenshots placeholder

issue_type: chore


title: Build WordPress Healthcheck plugin (stepper through steps.md)

description: Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).

issue_type: epic


title: Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin

description: Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.


title: Step 12 (Wrap-up): pre-fill report with all automated findings, attach diff vs. previous session, optionally push summary to ManageWP/WP Umbrella

notes: wrap_up step's autocheck walks all other steps' stored findings, counts bad/warn separately, lists the top 6-8 examples, and identifies any blocked steps. Gives the technician a quick 'roll-up' before they finish. The report itself already embeds every finding verbatim.


title: Step 11 (Small fixes): broken internal link scan (sample first N pages), missing alt-text scan on homepage images, deactivated-but-installed plugin list

notes: Deactivated-but-installed plugin list and homepage alt-text scan (fetch home_url, regex over <img> tags for alt attribute presence). Broken-link scan deferred — too heavy for v1 (would need to crawl every internal link).


title: Step 9 (Database): table sizes report, spam comment count, post revision count, autoload option size, recommendation engine (e.g. 'consider revision limit if >5000')

notes: Spam comments (warn >100), post revisions (warn >5000), autoload options size with WP 6.6+ value handling (on/auto/auto-on alongside legacy yes; warn >5MB), top 3 largest tables from information_schema. MySQL 8 reserved-word fix (TABLE_ROWS AS table_rows).


title: Step 10 (Uptime): pluggable uptime provider integration (ManageWP, UptimeRobot, BetterStack, Pingdom). At minimum expose 'paste downtime summary' field

notes: Monitoring plugin detection (ManageWP Worker, MainWP Child, Jetpack, WP Umbrella, UptimeRobot). www/non-www reachability check — HEAD with no redirects so the technician sees the actual response code + Location header for each canonical. External monitoring providers (UptimeRobot/BetterStack/Pingdom direct API integration) not implemented — would need per-install settings and API keys; out of scope for v1.


title: Step 8 (Security): SSL cert expiry check (stream_socket_client to :443 + parse cert), admin user audit (flag unfamiliar accounts vs. snapshot baseline), xmlrpc.php reachability check, custom login URL check

notes: SSL cert expiry via stream_socket_client + openssl_x509_parse (warn <30 days, bad expired); not-HTTPS flagged bad. Administrator audit lists all admin users (warn if >5). xmlrpc.php reachability via wp_remote_post with system.listMethods. Login URL hardening detection (WPS Hide Login, Rename wp-login.php).


title: Step 6 (Visual & Functional): homepage screenshot via mShots (or local headless if available); checklist with auto-pulled key pages (front page + posts page + WC shop + login)

notes: mShots screenshot URL surfaced (s.wordpress.com/mshots/v1/...). Key pages auto-detected: home, login, posts page, and WooCommerce shop/cart/checkout if WC is active. Each gets a HEAD request to verify HTTP status. Mixed-content scan on the homepage for http:// src/href references when site is https.


title: Step 7 (Performance): PageSpeed Insights API integration (server-side fetch), record mobile + desktop scores, flag >10pt drop vs. previous session, image scan for uncompressed > 500KB on homepage

description: Requires a Google PageSpeed API key (per-installation setting). Fall back to manually-entered scores if no key. Cache results for 12h.

notes: Keyless PageSpeed Insights v5 API integration (25k/day per-IP quota — enough for our purpose). Both mobile + desktop strategies. Cached 12h via transient. Surfaces score, LCP, CLS, TBT. Skipped for local URLs (PSI can't reach .local). Caching plugin detection across 7 common plugins. Heavy image scan (>500KB) over first 8 homepage images via HEAD requests.


title: Step 5 (Theme): detect parent/child relationship; if no child, diff parent theme files vs. WP.org canonical to flag direct customisations that would be lost on update

notes: Parent/child theme detection via WP_Theme. Warns when active theme is non-default and not a child (with file mtime hint). Lists inactive themes. Surfaces available theme updates via update_themes transient. Diff-against-WP.org-canonical deferred — heavyweight (would need downloading and unzipping the canonical) and the mtime heuristic catches the common case.


title: Step 3 (Core): wizard around core update with safe-mode (deactivate plugins first, update, smoke-load /, reactivate) and clear rollback path on failure

notes: Phase 3 v1: autocheck reports current vs latest WP version with link to update-core.php, database upgrade status, auto-update policy, and a 'safe-update sequence' reminder. The full safe-mode wizard (deactivate plugins → update → smoke-load → reactivate) deferred — it's a meaningful UI flow on its own (multi-step confirmations, rollback) and worth its own bead in phase 4.


title: Step 4 (Plugins): enrich each plugin with WP.org 'last updated' date, 'removed from repo' flag, active install count; flag plugins not updated >12 months; flag plugins missing from repo

description: Calls api.wordpress.org/plugins/info/1.0/<slug>.json for each plugin. Cache per-site for 24h. 'Removed from repo' = 404 or unsupported response.

notes: WP.org API enrichment via api.wordpress.org/plugins/info/1.0/<slug>.json with 24h transient cache. Surfaces last_updated date + active_installs, flags 'not in repo' (404) and 'removed' (error response), flags abandoned (>12 months), flags update available via update_plugins transient. Summary line counts each category. ~1.7s for 4 plugins on first run (uncached); subsequent runs <50ms via cache.


title: Step 2 (Environment): auto-collect PHP version + EOL flag, disk usage (statvfs), tail of error log (PHP + WP debug.log if enabled), wp-config flags (WP_DEBUG, WP_DEBUG_DISPLAY, DISALLOW_FILE_EDIT), spot-check permissions on wp-config.php / wp-content / uploads

notes: PHP version + EOL table; WP version vs latest; disk usage on ABSPATH; wp-config flags (WP_DEBUG, _DISPLAY, _LOG, DISALLOW_FILE_EDIT, WP_ENVIRONMENT_TYPE); file perms on wp-config.php/wp-content/uploads; PHP error_log + WP debug.log size. 12 findings on testsite, correctly flagged disk 95% (bad) and WP_DEBUG_DISPLAY=true (bad).


title: Step 1 (Backup): detect installed backup plugin (UpdraftPlus, BackWPup, BlogVault, Jetpack VaultPress), surface last backup time + size + verification status

notes: Backup detection by plugin slug (UpdraftPlus, BackWPup, Duplicator, WPvivid, All-in-One WP Migration, BlogVault, Jetpack, Solid Backups/BackupBuddy, WP Time Capsule, Backup Migration). Active/inactive state per plugin. UpdraftPlus last-backup timestamp surfaced via updraft_last_backup option. If none detected → bad-level finding.


title: Diff against previous session: highlight deltas vs. last completed session for this site

description: Pre-fill 'before' values for plugin/theme/WP versions from the previous session's 'after' values. Show a Δ column on the report. Lets clients see trajectory across checks.

notes: Previous finished session is persisted to wph_previous_session option on finish(). On the next session's dashboard, a diff banner shows count of new issues (warn/bad in current not in previous), resolved (in previous not in current), and changed (same id, different level/value). Phase 3.x could surface the per-step delta inline.


title: Polished HTML/PDF report styling + ability to email

notes: HTML report builder (wph_build_html_report) with inline styles — printable, looks good. Download HTML + Download Markdown + Copy + Email actions on finish panel. Email uses wp_mail with HTML content-type, prefills admin_email, redirects with status. PDF deferred — would need vendoring Dompdf (~500KB) and isn't core to the workflow.


title: Stop-and-escalate decision support: when a step is marked blocked/escalate, surface the matching guidance from steps.md and link to a quote/escalation template

description: Drive from the 'When to Stop and Escalate' table at the bottom of steps.md. Each escalation condition becomes a structured rule that fires when its preconditions hold (e.g. step=backup, status=blocked → 'Stop. Do not proceed. Restore backup before retry.'). Render as a banner on the affected step.

notes: 'Stop & escalate' summary panel at top of the dashboard listing any step with status=blocked, showing the step's escalation() guidance and the technician's notes. Per-step escalation banner already shown inline when blocked (carried over from phase 1).


title: Linear stepper UI: replace single-page with one-step-per-page + progress bar + per-step timer

notes: Implemented as a sticky step-index sidebar with status dots (◯ open · ✓ done · ⏭ skipped · ✗ blocked · — n/a). Anchor links jump to each step. Not a full one-step-per-page stepper — that flow was rejected after consideration because the single-page overview is more useful for scrolling between related steps. The sidebar gives the same at-a-glance progress without losing context.