From wordpress-expert
Classifies diagnostic findings as NEW or RECURRING by comparing current scan to prior trends.json, patches inline badges in latest.md, appends resolved findings list, and rotates 2-slot trend storage.
npx claudepluginhub dr-robert-li/cowork-wordpress-expertThis skill uses the workspace's default tool permissions.
> **CRITICAL SEQUENCING REQUIREMENT:** This skill MUST run AFTER report-generator has written `memory/{site}/latest.md`. It reads that file and patches it in-place. If it runs before report-generator completes, latest.md will be incomplete or absent and patching will fail.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
CRITICAL SEQUENCING REQUIREMENT: This skill MUST run AFTER report-generator has written
memory/{site}/latest.md. It reads that file and patches it in-place. If it runs before report-generator completes, latest.md will be incomplete or absent and patching will fail.
You classify findings from the current diagnostic scan as [NEW] or [RECURRING] by comparing against the prior scan stored in memory/{site}/trends.json. You patch inline badges onto finding headings in latest.md, append a resolved-findings summary, and write the updated trends.json with a 2-slot rotation (current becomes prior on the next scan).
After each diagnostic scan, users want to know whether issues are new or have persisted since the last scan. This skill:
trends.json (if it exists)[NEW] or [RECURRING] badges onto finding headings in latest.mdtrends.json with 2-slot rotation (prior_scan = old current_scan, current_scan = this scan)What this skill does NOT do:
wp commands)What this skill DOES do:
memory/{site}/trends.json for prior scan data (if exists)memory/{site}/latest.md with inline trend badgesmemory/{site}/trends.jsonFirst scan behavior: On first scan, no badges are patched (clean report), and trends.json is written with prior_scan: null. This ensures the first report is uncluttered and the trend data store is initialized correctly.
Received from /diagnose Section 5.5:
| Variable | Source | Description |
|---|---|---|
COMBINED_FINDINGS | /diagnose Section 4 | JSON array of all findings from current scan |
SITE_NAME | /diagnose Section 1 | Site identifier for memory/ path resolution |
HEALTH_GRADE | /diagnose Section 5 | Letter grade A-F or "Incomplete" |
CRITICAL_TOTAL | /diagnose Section 5 | Count of Critical severity findings |
WARNING_TOTAL | /diagnose Section 5 | Count of Warning severity findings |
INFO_TOTAL | /diagnose Section 5 | Count of Info severity findings |
SKILLS_COMPLETED | /diagnose Section 4 | Count of skills that actually ran (not skipped) |
SKILLS_TOTAL | /diagnose Section 4 | Total skills attempted in this mode |
Read memory/${SITE_NAME}/trends.json if it exists and extract the prior scan's findings as the comparison baseline.
MEMORY_DIR="memory/${SITE_NAME}"
TRENDS_FILE="${MEMORY_DIR}/trends.json"
LATEST_FILE="${MEMORY_DIR}/latest.md"
# Determine if this is the first scan
IS_FIRST_SCAN=true
PRIOR_FINDINGS='[]'
PRIOR_SCAN_DATE=""
if [ -f "$TRENDS_FILE" ]; then
# Extract prior scan data from current_scan slot (it becomes the comparison baseline)
PRIOR_FINDINGS=$(jq -r '.current_scan.findings // []' "$TRENDS_FILE")
PRIOR_SCAN_DATE=$(jq -r '.current_scan.scan_date // ""' "$TRENDS_FILE")
# Only set IS_FIRST_SCAN=false if we actually have prior findings to compare against
PRIOR_COUNT=$(echo "$PRIOR_FINDINGS" | jq 'length')
if [ "$PRIOR_COUNT" -gt 0 ] || [ -n "$PRIOR_SCAN_DATE" ]; then
IS_FIRST_SCAN=false
fi
fi
Why current_scan becomes the baseline: trends.json always stores the most recent scan as current_scan. When this skill runs, it needs to compare against what was stored from the last run — which is current_scan. After classification, this skill will shift current_scan to prior_scan and write a new current_scan.
If a prior scan exists, compute how many days ago it was. If 90+ days, set a staleness note to append to the report.
STALENESS_NOTE=""
if [ "$IS_FIRST_SCAN" = false ] && [ -n "$PRIOR_SCAN_DATE" ]; then
# Get current timestamp as epoch
NOW_EPOCH=$(date +%s)
# Parse prior scan date to epoch (macOS and Linux compatible)
if date --version >/dev/null 2>&1; then
# GNU date (Linux)
PRIOR_EPOCH=$(date -d "$PRIOR_SCAN_DATE" +%s 2>/dev/null || echo "0")
else
# macOS date (BSD)
PRIOR_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$PRIOR_SCAN_DATE" +%s 2>/dev/null || echo "0")
fi
if [ "$PRIOR_EPOCH" -gt 0 ]; then
SECONDS_AGO=$((NOW_EPOCH - PRIOR_EPOCH))
DAYS_AGO=$((SECONDS_AGO / 86400))
if [ "$DAYS_AGO" -ge 90 ]; then
STALENESS_NOTE="> Note: Prior scan was ${DAYS_AGO} days ago — trend data may be less meaningful."
fi
fi
fi
Implement classify_finding with two-pass matching to determine whether each finding is NEW or RECURRING.
Algorithm:
For each finding in COMBINED_FINDINGS:
.id field exactly matches any .id in PRIOR_FINDINGS. If yes: RECURRING..location). If yes: RECURRING.# Extract finding_type from an ID by stripping the last hyphen-hash segment
# Example: "SECR-CFGSEC-a1b2c3" → "SECR-CFGSEC"
# Example: "PERF-N1QUERY-wp-content-plugins-my-plugin-php-d4e5f6" → strips last segment
get_finding_type() {
local FINDING_ID="$1"
echo "$FINDING_ID" | rev | cut -d'-' -f2- | rev
}
# Build CLASSIFICATIONS array: [{id, badge}]
CLASSIFICATIONS='[]'
# Iterate over current findings and classify each one
FINDING_COUNT=$(echo "$COMBINED_FINDINGS" | jq 'length')
for i in $(seq 0 $((FINDING_COUNT - 1))); do
FINDING=$(echo "$COMBINED_FINDINGS" | jq ".[$i]")
FINDING_ID=$(echo "$FINDING" | jq -r '.id')
FINDING_LOCATION=$(echo "$FINDING" | jq -r '.location // ""')
FINDING_TYPE=$(get_finding_type "$FINDING_ID")
BADGE="NEW"
if [ "$IS_FIRST_SCAN" = false ]; then
# Pass 1: Exact ID match
EXACT_MATCH=$(echo "$PRIOR_FINDINGS" | jq --arg id "$FINDING_ID" '[.[] | select(.id == $id)] | length')
if [ "$EXACT_MATCH" -gt 0 ]; then
BADGE="RECURRING"
else
# Pass 2: Fuzzy match on finding_type + file_path
FUZZY_MATCH=$(echo "$PRIOR_FINDINGS" | jq --arg ftype "$FINDING_TYPE" --arg floc "$FINDING_LOCATION" \
'[.[] | select(
(.id | split("-") | .[:-1] | join("-")) == $ftype
and (.file_path == $floc or .location == $floc)
)] | length')
if [ "$FUZZY_MATCH" -gt 0 ]; then
BADGE="RECURRING"
fi
fi
fi
CLASSIFICATIONS=$(echo "$CLASSIFICATIONS" | jq \
--arg id "$FINDING_ID" \
--arg badge "$BADGE" \
'. + [{"id": $id, "badge": $badge}]')
done
REGRESSION limitation: REGRESSION classification (a finding that was resolved and reappeared) requires 3+ scan history. With the 2-scan retention policy (current + prior), reappeared findings are classified as [NEW] because the resolution event is not recorded. This is a known limitation of the 2-scan retention policy. Plan 08-02 (comparison matrix) will surface these patterns through grade-over-grade comparisons.
Fuzzy match risk: Fuzzy matching on (finding_type + file_path) may produce false RECURRING classifications when multiple findings of the same type exist in the same file (e.g., two N+1 query patterns in the same plugin file). This is an accepted trade-off for catching reformatted code where the content hash changes but the structural finding is the same.
Findings that were in the prior scan but are NOT in the current scan are RESOLVED. Collect their titles for the resolved summary.
RESOLVED_TITLES=()
if [ "$IS_FIRST_SCAN" = false ]; then
PRIOR_COUNT=$(echo "$PRIOR_FINDINGS" | jq 'length')
for i in $(seq 0 $((PRIOR_COUNT - 1))); do
PRIOR_FINDING=$(echo "$PRIOR_FINDINGS" | jq ".[$i]")
PRIOR_ID=$(echo "$PRIOR_FINDING" | jq -r '.id')
PRIOR_TITLE=$(echo "$PRIOR_FINDING" | jq -r '.title')
PRIOR_TYPE=$(get_finding_type "$PRIOR_ID")
PRIOR_LOCATION=$(echo "$PRIOR_FINDING" | jq -r '.file_path // .location // ""')
# Check if this prior finding exists in current scan (exact or fuzzy)
EXACT_MATCH=$(echo "$COMBINED_FINDINGS" | jq --arg id "$PRIOR_ID" '[.[] | select(.id == $id)] | length')
if [ "$EXACT_MATCH" -eq 0 ]; then
# Fuzzy check: same type + location in current findings
FUZZY_MATCH=$(echo "$COMBINED_FINDINGS" | jq --arg ftype "$PRIOR_TYPE" --arg floc "$PRIOR_LOCATION" \
'[.[] | select(
((.id | split("-") | .[:-1] | join("-")) == $ftype)
and (.location == $floc)
)] | length')
if [ "$FUZZY_MATCH" -eq 0 ]; then
RESOLVED_TITLES+=("$PRIOR_TITLE")
fi
fi
done
fi
Only runs when IS_FIRST_SCAN=false. Use sed to append the badge to the heading line for each finding in latest.md.
Report headings follow the format: ### {FINDING_ID}: {title}
The badge is appended after the title text.
if [ "$IS_FIRST_SCAN" = false ] && [ -f "$LATEST_FILE" ]; then
CLASSIF_COUNT=$(echo "$CLASSIFICATIONS" | jq 'length')
for i in $(seq 0 $((CLASSIF_COUNT - 1))); do
ENTRY=$(echo "$CLASSIFICATIONS" | jq ".[$i]")
FINDING_ID=$(echo "$ENTRY" | jq -r '.id')
BADGE=$(echo "$ENTRY" | jq -r '.badge')
# Escape special characters in FINDING_ID for sed
# IDs contain hyphens and alphanumerics — safe for basic sed patterns
# macOS: sed -i ''; Linux: sed -i (without '')
sed -i '' "s|^### ${FINDING_ID}: \(.*\)$|### ${FINDING_ID}: \1 [${BADGE}]|" "$LATEST_FILE" 2>/dev/null \
|| sed -i "s|^### ${FINDING_ID}: \(.*\)$|### ${FINDING_ID}: \1 [${BADGE}]|" "$LATEST_FILE"
done
fi
macOS/Linux compatibility note: The sed -i '' syntax is required on macOS (BSD sed). Linux (GNU sed) uses sed -i without the empty string argument. The fallback pattern above tries macOS first and falls back to Linux if the first command fails.
Only runs when IS_FIRST_SCAN=false. If there are resolved findings, append a summary section to latest.md. Always append the staleness note if set.
if [ "$IS_FIRST_SCAN" = false ] && [ -f "$LATEST_FILE" ]; then
# Append resolved findings section if any findings were resolved
if [ "${#RESOLVED_TITLES[@]}" -gt 0 ]; then
{
echo ""
echo "---"
echo "## Resolved Since Last Scan"
echo ""
echo "The following findings from the prior scan are no longer detected:"
for TITLE in "${RESOLVED_TITLES[@]}"; do
echo "- ${TITLE}"
done
} >> "$LATEST_FILE"
# Append staleness note after resolved section if set
if [ -n "$STALENESS_NOTE" ]; then
echo "" >> "$LATEST_FILE"
echo "$STALENESS_NOTE" >> "$LATEST_FILE"
fi
elif [ -n "$STALENESS_NOTE" ]; then
# No resolved findings but staleness note exists — append to end of report
{
echo ""
echo "---"
echo ""
echo "$STALENESS_NOTE"
} >> "$LATEST_FILE"
fi
fi
Build the current scan's findings record from COMBINED_FINDINGS (tracking fields only), then write the updated trends.json with 2-slot rotation.
Tracking fields extracted from each finding:
id — deterministic finding identifiertitle — finding title for resolved displayfinding_type — derived from ID by stripping last hash segment (e.g., SECR-CFGSEC)file_path — from .location field (used for fuzzy matching on next scan)severity — Critical, Warning, or Infocontent_hash — last segment of the ID (the hash portion, e.g., a1b2c3)SCAN_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
SKILL_COVERAGE="${SKILLS_COMPLETED}/${SKILLS_TOTAL}"
# Build current scan findings array with only tracking fields
CURRENT_SCAN_FINDINGS=$(echo "$COMBINED_FINDINGS" | jq '[.[] | {
id: .id,
title: .title,
finding_type: (.id | split("-") | .[:-1] | join("-")),
file_path: (.location // ""),
severity: .severity,
content_hash: (.id | split("-") | last)
}]')
# Build the new current_scan object
NEW_CURRENT_SCAN=$(jq -n \
--arg scan_date "$SCAN_DATE" \
--arg grade "$HEALTH_GRADE" \
--argjson critical "$CRITICAL_TOTAL" \
--argjson warning "$WARNING_TOTAL" \
--argjson info "$INFO_TOTAL" \
--arg skill_coverage "$SKILL_COVERAGE" \
--argjson findings "$CURRENT_SCAN_FINDINGS" \
'{
scan_date: $scan_date,
grade: $grade,
critical_count: $critical,
warning_count: $warning,
info_count: $info,
skill_coverage: $skill_coverage,
findings: $findings
}')
# Build the full trends.json (shift current_scan → prior_scan if it exists)
if [ -f "$TRENDS_FILE" ]; then
# Read existing current_scan to become the new prior_scan
EXISTING_CURRENT=$(jq '.current_scan' "$TRENDS_FILE")
jq -n \
--arg site "$SITE_NAME" \
--arg updated_at "$SCAN_DATE" \
--argjson prior_scan "$EXISTING_CURRENT" \
--argjson current_scan "$NEW_CURRENT_SCAN" \
'{
site: $site,
updated_at: $updated_at,
prior_scan: $prior_scan,
current_scan: $current_scan
}' > /tmp/trends.json.tmp && mv /tmp/trends.json.tmp "$TRENDS_FILE"
else
# First scan: write with null prior_scan
jq -n \
--arg site "$SITE_NAME" \
--arg updated_at "$SCAN_DATE" \
--argjson current_scan "$NEW_CURRENT_SCAN" \
'{
site: $site,
updated_at: $updated_at,
prior_scan: null,
current_scan: $current_scan
}' > /tmp/trends.json.tmp && mv /tmp/trends.json.tmp "$TRENDS_FILE"
fi
Write safety: The temp file + mv pattern (> /tmp/trends.json.tmp && mv) prevents partial writes from corrupting the trends.json file if the jq command fails or is interrupted.
{
"site": "mysite",
"updated_at": "2026-02-19T06:00:00Z",
"prior_scan": {
"scan_date": "2026-01-15T10:00:00Z",
"grade": "C",
"critical_count": 1,
"warning_count": 4,
"info_count": 6,
"skill_coverage": "14/16",
"findings": [
{
"id": "SECR-CFGSEC-a1b2c3",
"title": "wp-config.php world-readable",
"finding_type": "SECR-CFGSEC",
"file_path": "wp-config.php",
"severity": "Critical",
"content_hash": "a1b2c3"
}
]
},
"current_scan": {
"scan_date": "2026-02-19T06:00:00Z",
"grade": "B",
"critical_count": 0,
"warning_count": 3,
"info_count": 5,
"skill_coverage": "16/16",
"findings": [
{
"id": "CQ-SQLINJ-d4e5f6",
"title": "Direct SQL query without prepare",
"finding_type": "CQ-SQLINJ",
"file_path": "wp-content/plugins/my-plugin/includes/db.php",
"severity": "Warning",
"content_hash": "d4e5f6"
}
]
}
}
Retention policy: trends.json retains exactly 2 scan slots — current_scan and prior_scan. On each run, current_scan becomes prior_scan and a new current_scan is written. The scan before prior_scan is discarded. This is the 2-scan retention policy.
Why 2 slots: Sufficient for NEW/RECURRING classification per the locked user decision. Plan 08-02 uses the grade and count fields from both slots for the comparison matrix without requiring deeper history.
This skill produces no findings JSON (it does not add to COMBINED_FINDINGS). Its outputs are:
memory/{site}/latest.md — patched in-place with inline [NEW] / [RECURRING] badges on finding headings, optional resolved-findings section, and optional staleness notememory/{site}/trends.json — updated with 2-slot rotationDisplay to user (from /diagnose Section 5.5):
Trend tracking is complete when:
memory/{site}/trends.json with correct 2-slot schema