Files
prautoblogger/CONVENTIONS.md
peptiderepo 50da840553 feat(runs): state machine, idempotent resume, reaper extension, retention
Pipeline v2 Phase 1, commit 4/4. Run_Stage_State persists per-run
per-stage state keyed run_id + stage + agent_role + item_key (item_key
scopes articles because one run_id spans a whole batch; agent_role is
the Phase-2 quorum dimension, schema-only). Done stages snapshot output
and are reused on re-entry — writer stages via
Content_Generator::set_run_item, the review via its stored verdict, a
published item skipped outright — never re-run, never re-charged. Post
creation is keyed by _prautoblogger_run_id + new _prautoblogger_idea_hash
(check-before-insert in Publisher) so retries cannot duplicate posts.
Run-level research/analysis checkpoints + done/failed lifecycle marks
ride Pipeline_Runner/Executor (incl. orphan-abort + budget-abort paths).

Run_Reaper rides the EXISTING #19 daily cron: stages stuck running >2x
expected stage wall-clock and runs silent >2x expected run wall-clock ->
failed (filterable), open stages reaped, queryable as 'incomplete'.
Retention: request_json + stage output payloads NULLed after R days, R
from the new Audit Payload Retention setting (default constant 14,
0 = keep forever, no literal at point of use). Audit_Writer records
editorial verdicts (run_decisions) and per-article kept sources
(run_sources) for new runs.

Under-300: status-transient/summary helpers extracted verbatim to
Pipeline_Status (Pipeline_Runner 271; removed the worker's duplicate).

Agent-Session: pipeline-v2-phase1-engineer-20260612
2026-06-11 16:42:56 +07:00

23 KiB
Raw Blame History

PRAutoBlogger — Conventions

This document codifies the naming patterns, coding conventions, and step-by-step guides for extending PRAutoBlogger. Update this file whenever a new pattern is introduced.


Naming Conventions

PHP Classes

  • Format: PRAutoBlogger_{PascalCase} (e.g., PRAutoBlogger_Content_Generator)
  • File naming: class-{kebab-case}.php (e.g., class-content-generator.php)
  • Interfaces: interface-{kebab-case}.php with PRAutoBlogger_{Name}_Interface class name

Hooks (Actions & Filters)

  • Prefix: All hooks start with prautoblogger_
  • Actions: prautoblogger_{event} (e.g., prautoblogger_before_content_generation)
  • Filters: prautoblogger_filter_{what} (e.g., prautoblogger_filter_article_idea_score)
  • Hook callbacks registered in: class-prautoblogger.php (central loader), never in the classes themselves

Database

  • Table prefix: {$wpdb->prefix}prautoblogger_ (e.g., wp_prautoblogger_source_data)
  • Option prefix: prautoblogger_ (e.g., prautoblogger_openrouter_api_key)
  • Post meta prefix: _prautoblogger_ (underscore prefix hides from custom fields UI)
  • Transient prefix: prautoblogger_ (e.g., prautoblogger_reddit_token)

Constants

  • Format: PRAUTOBLOGGER_{UPPER_SNAKE} (e.g., PRAUTOBLOGGER_MAX_RETRIES)
  • Defined in: prautoblogger.php (bootstrap file) for global constants

Hook Callback Methods

  • Action callbacks: Prefixed with on_ (e.g., on_daily_generation_triggered)
  • Filter callbacks: Prefixed with filter_ (e.g., filter_content_quality)

Error Handling & Logging

Structured Logger

All logging uses PRAutoBlogger_Logger (singleton) instead of raw error_log(). The logger writes to the prab_event_log custom table and forwards errors/warnings to PHP's error_log() for server-level monitoring. The verbosity threshold is user-configurable via Settings → Publishing → Log Level.

Never call error_log() directly. Always use the Logger:

$logger = PRAutoBlogger_Logger::instance();
$logger->error( 'API call failed: ' . $error_message, 'openrouter' );
$logger->warning( 'Budget nearing limit.', 'cost-tracker' );
$logger->info( 'Pipeline complete: 3 posts generated.', 'pipeline' );
$logger->debug( 'Token counts: prompt=1200, completion=800', 'openrouter' );

Log levels (ascending verbosity)

Level Numeric Use for
error 0 Failures: API errors, parse failures, DB write failures
warning 1 Degraded behavior: budget approaching, missing providers, fallback pricing
info 2 Key events: pipeline start/stop, articles generated, metrics collected
debug 3 Verbose detail: token counts, timing, skipped posts, intermediate values

Context strings

The second argument to every log call is a short context tag identifying the origin: pipeline, scheduler, collector, analyzer, editor, openrouter, reddit, ga4, metrics, cost-tracker, encryption.

What gets shown to the user

  • API key issues → admin notice (persistent until resolved)
  • Budget exceeded → admin notice + email to site admin
  • Generation failures → logged to prab_generation_log with response_status = 'error'
  • Generation failures also shown in the admin dashboard widget
  • All log entries visible in PRAutoBlogger → Activity Log (filterable by level)

What gets retried

  • LLM API calls: exponential backoff, max 3 retries, then fail loudly
  • Reddit API calls: exponential backoff, max 3 retries, then skip this collection cycle
  • Never retry silently. Every retry is logged. Every final failure is surfaced.

Error severity levels

  1. Fatal: Plugin cannot function (missing API key, budget exhausted) → admin notice, stop all scheduled jobs
  2. Retriable: Transient API failure → retry with backoff, log each attempt
  3. Skippable: Single post generation failed → log, skip this post, continue with others

How To: Add a New LLM Provider

  1. Create includes/providers/class-{name}-provider.php
  2. Implement PRAutoBlogger_LLM_Provider_Interface (see interface-llm-provider.php)
  3. Required methods: send_chat_completion(), get_available_models(), estimate_cost()
  4. Add provider option to the settings page in admin/class-admin-page.php
  5. Register the provider in class-prautoblogger.php loader
  6. Update ARCHITECTURE.md external API integrations table

Interface contract:

interface PRAutoBlogger_LLM_Provider_Interface {
    public function send_chat_completion(array $messages, string $model, array $options = []): array;
    public function get_available_models(): array;
    public function estimate_cost(string $model, int $prompt_tokens, int $completion_tokens): float;
    public function get_provider_name(): string;
}

How To: Add a New Source Provider (Social Platform)

  1. Create includes/providers/class-{platform}-provider.php
  2. Implement PRAutoBlogger_Source_Provider_Interface (see interface-source-provider.php)
  3. Required methods: collect_data(), get_source_type(), validate_credentials()
  4. Add API credentials fields to the settings page in admin/class-admin-page.php
  5. Register the provider in class-prautoblogger.php loader
  6. Add the source type string to the ab_source_data.source_type allowed values
  7. Update ARCHITECTURE.md file tree and external API integrations table

Interface contract:

interface PRAutoBlogger_Source_Provider_Interface {
    public function collect_data(array $config): array; // Returns PRAutoBlogger_Source_Data[]
    public function get_source_type(): string;           // e.g., 'reddit', 'tiktok'
    public function validate_credentials(): bool;
    public function get_rate_limit_status(): array;
}

How To: Add a New Image Provider

  1. Create includes/providers/class-{name}-image-provider.php
  2. Implement PRAutoBlogger_Image_Provider_Interface (see interface-image-provider.php)
  3. Required methods: generate_image(), estimate_cost(), get_provider_name(), validate_credentials_detailed()
  4. Split helpers into companions if you'd exceed the 300-line cap. The Cloudflare provider splits into provider + pricing + validator — mirror that shape.
  5. Add API credentials fields to admin/class-settings-fields.php under the prautoblogger_images section
  6. Register the provider in class-prautoblogger.php loader once the image pipeline lands (commit 1b of the image workstream)
  7. Update ARCHITECTURE.md file tree, external API integrations table, and the options table

Interface contract:

interface PRAutoBlogger_Image_Provider_Interface {
    public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array; // {bytes, mime_type, width, height, model, seed, cost_usd, latency_ms}
    public function estimate_cost( int $width, int $height, array $options = [] ): float;
    public function get_provider_name(): string;                                   // e.g., 'cloudflare_workers_ai'
    public function validate_credentials_detailed(): array;                         // {status, message, debug?}
}

Retry + cost rules (non-negotiable)

  • 5xx / 429 / network errors: retry with exponential backoff up to PRAUTOBLOGGER_MAX_RETRIES, respect Retry-After when present.
  • 4xx other than 429: fail loudly on the first response — never retry a deterministic bad request.
  • Validation check must be non-destructive (no image generation).
  • Every attempt logs via PRAutoBlogger_Logger with context {provider-slug}-image.

How To: Tune the Image Composer (v0.17.0+)

The deterministic composer (ARCHITECTURE.md #21) renders branded variants between provider bytes and sideload. Three rules when touching it:

  1. Geometry lives in code, not settings. All sizes/colors/opacities come from PRAutoBlogger_Image_Composer_Layout::defaults() and can be overridden via the prautoblogger_image_compose_layout filter:
    add_filter( 'prautoblogger_image_compose_layout', function ( array $layout ): array {
        $layout['featured']['mark_opacity'] = 0.40;
        return $layout;
    } );
    
    Promote a value to a settings field only on explicit CEO request.
  2. Keep renderer classes WP-free. Image_Composer_Imagick, Image_Composer_Canvas, and Image_Composer_Layout must not call WordPress functions — they are exercised by a standalone render harness (and the determinism test) without a WP bootstrap. Only the orchestrator (Image_Composer) and the GD rung (Image_Composer_Editor) may touch WP APIs.
  3. Never fatal a publish. Any new render path needs a try/catch that degrades to pass-through and logs at most one WARNING per run (warn_once()). Capability checks go through the cached probe (prautoblogger_image_compose_capability option + same-named override filter) — never re-probe per image.

Adding a new variant role: extend Layout::defaults() + the renderer, whitelist the role in Image_Composer::configured_variants() and in Image_Attacher::ROLE_POST_META (new _prautoblogger_{role}_image_id post meta keeps the uninstall prefix-sweep effective), then cover it in the unit ladder tests.


How To: Add a New Admin Setting

  1. Define the option key in the settings array in admin/class-admin-page.php → get_settings_fields() method
  2. Format: add an entry to the appropriate section array:
    [
        'id'          => 'prautoblogger_new_setting',
        'label'       => __('Setting Label', 'prautoblogger'),
        'type'        => 'text', // text, number, select, textarea, checkbox, password
        'default'     => '',
        'description' => __('Help text shown below the field.', 'prautoblogger'),
        'sanitize'    => 'sanitize_text_field', // sanitization callback
    ]
    
  3. The settings page renderer handles registration, rendering, and sanitization automatically from this array.
  4. Access the setting anywhere with: get_option('prautoblogger_new_setting', $default)
  5. For encrypted settings (API keys): use PRAutoBlogger_Encryption::encrypt() / decrypt()

How To: Add a New Pipeline Stage

Writing stages are methods on Content_Generator that delegate to its execute_stage() helper (Opik span + LLM dispatch + cost log + run-stage state in one place).

  1. Add the stage method to class-content-generator.php and call $this->execute_stage( $stage, $span_name, $user_prompt, $request, $model, $options )
  2. Wire it into generate_multi_step() (or the relevant mode path)
  3. Add the stage to PRAutoBlogger_Stage_Display_Map (label, default agent role, prompt key) and — if it has registry-managed copy — a prompt key in the defaults classes
  4. Cost logging and per-run stage state (idempotent resume, output snapshot) come free from execute_stage(); the per-run cost governor guards the call inside the provider
  5. If a stage fails after retries, the entire pipeline fails (no partial publishes); the stage row is failed by the worker's catch-all and the run is swept by Run_Reaper

How To: Add a New Frontend Component

Frontend components use wp.element (WordPress-bundled React) — no JSX, no build step.

  1. Create the PHP class in includes/frontend/class-{name}.php:

    • Register a shortcode via add_shortcode() in an on_register_shortcode() method
    • Register a REST endpoint if the component needs async data (in on_register_rest_route())
    • Enqueue JS (wp-element dependency) and CSS via wp_enqueue_script/style()
    • Pass config to JS via wp_localize_script()
  2. Create JS in assets/js/{name}.js:

    • Use wp.element.createElement (aliased as el) for rendering
    • Import hooks: var useState = wp.element.useState;
    • Read config from window.{localizedObjectName}
    • Handle all states: loading, loaded, empty, error
    • Mount into the shortcode's <div id="..."> mount point
  3. Create CSS in assets/css/{name}.css:

    • Use Peptide Starter theme CSS custom properties with fallbacks: var(--color-text-primary, #f9fafb)
    • Prefix all classes with prab- to avoid collisions
    • Include responsive breakpoints, focus-visible states, and loading skeletons
  4. Wire hooks in class-prautoblogger.php:

    • Instantiate the PHP class in register_frontend_hooks()
    • Register shortcode on init, REST routes on rest_api_init
  5. Add 'frontend/' to the autoloader's $directories array (already done for the first component)

  6. Update ARCHITECTURE.md file tree and add a data flow section

REST endpoint conventions:

  • Namespace: prautoblogger/v1
  • Permission callback: use a filter hook for extensibility (e.g., prautoblogger_rest_{name}_public)
  • Sanitize all params with sanitize_callback in arg registration
  • Return only the fields the frontend needs — no unnecessary data
  • Set Cache-Control header for cacheable responses

Code Quality Rules

  1. declare(strict_types=1); in every PHP file
  2. Type declarations on all method parameters, return types, and class properties
  3. No file exceeds 300 lines. Split when approaching this limit
  4. Every class has a 3-question preamble docblock: What / Who triggers / Dependencies
  5. Every public method has a docblock with @param, @return, and side effects
  6. @see references at the top of files that participate in multi-class flows
  7. All strings translatable via __() / _e() with prautoblogger text domain
  8. No magic methods without explicit justification documented in code
  9. No echo of raw data — always escape with esc_html(), esc_attr(), etc.
  10. Nonce on every form/AJAX — wp_nonce_field() / check_ajax_referer()

How To: Add an OpenRouter model picker field (v0.11.0+)

Use the model_select field type to let admins choose OpenRouter models with a searchable dropdown, capability filtering, and estimated cost preview based on usage history.

1. Declare the field in class-settings-fields.php

[
    'id'          => 'my_plugin_model',
    'label'       => __( 'My Model Setting', 'my-plugin' ),
    'type'        => 'model_select',
    'section'     => 'my_plugin_settings',
    'default'     => 'anthropic/claude-3.5-haiku',
    'capability'  => 'text→text',  // see ARCHITECTURE.md §18 for all capability strings
    'description' => __( 'Choose a model...', 'my-plugin' ),
    'badge'       => __( 'Quality', 'my-plugin' ),
],

2. Map the setting to pipeline stages (for cost preview)

If you want cost preview to work, add your setting ID and stage list to the get_stages_for_setting() method in includes/admin/fields/class-open-router-model-field.php:

private static function get_stages_for_setting( string $field_id ): array {
    $map = [
        'prautoblogger_analysis_model'     => [ 'analysis' ],
        'prautoblogger_writing_model'      => [ 'outline', 'draft', 'polish' ],
        'prautoblogger_editor_model'       => [ 'review' ],
        'my_new_model_setting'             => [ 'my_stage' ],  // ADD HERE
    ];
    return $map[ $field_id ] ?? [];
}

The cost preview queries get_avg_tokens_for_stages() on the Cost Tracker to show estimated cost per generation based on the last 30 days of usage.

3. That's it

The field renderer, AJAX refresh, model picker JS, and capability filtering are already wired. The admin will see a "Refresh Model List" button on the AI Models tab (or whichever tab contains the picker field).

Capability strings (locked in v0.11.0)

  • text→text — text input, text output (default for analysis/writing/editor)
  • text+image→text — vision models
  • text+audio→text — audio input models
  • text→image — image generation
  • text→audio — voice synthesis
  • text→video — video generation
  • text→embedding — embeddings

Phase 3 will add provider selection; v0.11.0 is OpenRouter-only.

How To: Change a Pipeline Prompt (v0.18.0+)

Prompt copy is versioned data, not code. The bodies live in wp_prautoblogger_prompts (one ACTIVE version per key); the hardcoded texts in class-prompt-defaults.php / class-prompt-defaults-editorial.php are the canonical v1 seed AND the fallback when the table is unavailable — never edit a stored version in place.

  1. Never UPDATE a body. Create a new version and activate it:
    $v = PRAutoBlogger_Prompt_Registry_Writer::create_version(
        'content.polish', $new_body, $model, $params, 'your-name', true
    );
    
  2. Keys are dot-namespaced: content.system, content.single_pass, content.outline, content.draft, content.polish, analysis.system, analysis.user, editor.system, editor.review, research.system, image.rewriter_system, image.style_template. The two image.* keys are the image-composer seam — coordinate on the convo thread before touching them.
  3. Tokens use the {{ name }} syntax (exactly one space each side — same convention as the Style Template's {{ topic_summary }}). Token VALUES are computed by code (Content_Prompts, Analysis_Prompts, Chief_Editor); a new prompt version may reorder or drop tokens but must not invent new ones without adding the value at the call site.
  4. Pinning. A run pins the active version of every key at start (runs.pinned_prompts_json, written by Cost_Tracker::set_run_id()); every generation_log row is stamped with the pinned prompt_version. Activating a new version affects the NEXT run, never a run in flight.
  5. Call sites render via PRAutoBlogger_Prompt_Registry::render( $key, $tokens ) and must keep working when the table is absent (the registry falls back to the defaults automatically — do not add your own fallback).
  6. If you add a key: define the body in the appropriate defaults class, add it to defs(), map its stage in Stage_Display_Map, and it will seed on the next migration pass.

Git Workflow — PR-Gated, Soft-Enforced

This repo is private on GitHub's free plan, which does not support branch protection or rulesets. The review gate is enforced at the agent layer, not server-side. Every agent and human contributor follows these rules; the .github/workflows/main-push-audit.yml tripwire opens an audit issue on any direct push to main that did not come from a merged PR.

Rules

  1. Never push to main directly. Every change lands via a pull request that the maintainer merges. No self-merging. No force-pushing to main.

  2. Branch naming: claude/<scope>-<YYYYMMDD> for agent-authored work, fix/<scope> or feat/<scope> for human-authored. The scope is a 1–3 word kebab-case description.

  3. Commit trailer: every commit authored by an agent must end with an Agent-Session: trailer so commits can be correlated back to the conversation that produced them, even though all commits share the peptiderepo bot identity.

    feat: add per-request cost tracking
    
    Agent-Session: cowork-2026-04-14-cost-audit
    
  4. PR description template: every PR description covers

    • What changed (one paragraph)
    • Why (motivation or incident link)
    • Risk flags (schema changes, API contract changes, cost impact, compatibility)
    • Test plan (what was run locally, what to smoke-test after merge)
  5. Emergency push exception: if a situation genuinely requires pushing to main without a PR (site down, CI broken, one-line hotfix), surface it in the chat before doing it. Every emergency push gets a follow-up PR that commits the same changes through the normal flow so git stays the source of truth. The tripwire will open an audit issue — close it with a comment explaining why.

Opening a PR from an agent session

The gh CLI is not installed in the Cowork sandbox. Use curl with the PAT from the workspace .env.credentials:

GH_TOKEN=$(grep "^GITHUB_PAT=" "$WORKSPACE/.env.credentials" | cut -d= -f2)

git push -u origin HEAD

curl -s -X POST -H "Authorization: token $GH_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.github.com/repos/peptiderepo/<repo>/pulls" \
  -d "$(jq -cn --arg title "<title>" --arg head "<branch>" --arg body "<body>" \
      '{title:$title, head:$head, base:"main", body:$body}')"

When this changes

If the peptiderepo GitHub account is ever upgraded to Pro (or the repo goes public), replace this soft-enforcement section with a note pointing to the real branch protection rules in repo settings.

Instrumenting a New LLM Call Site for Opik (v0.12.0+)

When you add a new LLM call (via $llm->send_chat_completion()), follow this pattern to capture it in Opik:

1. Get the trace context

At the start of your method, grab the per-request singleton:

$ctx = PRAutoBlogger_Opik_Trace_Context::current();

2. Start the span

Before the LLM call, declare a span with metadata:

$span_id = $ctx->start_span(
	array(
		'name'     => 'my_llm_operation',  // kebab-case, descriptive
		'type'     => 'llm',
		'model'    => $model,               // e.g. 'gpt-4', from get_option()
		'provider' => 'openrouter',         // or the actual provider name
		'input'    => array( 'key' => 'value' ), // optional metadata (no full prompts!)
	)
);

3. Make the LLM call (unchanged)

$response = $this->llm->send_chat_completion(
	array(
		array( 'role' => 'system', 'content' => $system_prompt ),
		array( 'role' => 'user', 'content' => $user_prompt ),
	),
	$model,
	array( 'temperature' => 0.7, 'max_tokens' => 4000 )
);

4. End the span with output

After the response, record the outcome:

$ctx->end_span(
	$span_id,
	array(
		'output' => array( 'response_length' => strlen( $response['content'] ?? '' ) ),
		'usage'  => array(
			'prompt_tokens'     => $response['prompt_tokens'],
			'completion_tokens' => $response['completion_tokens'],
			'total_tokens'      => $response['prompt_tokens'] + $response['completion_tokens'],
		),
	)
);

Key rules

  • Never log full prompts. Use prompt hashes or version tags in input. Full text blows payload size and leaks content policy.
  • Check the feature flag at call-site level? No. The singleton is always available; Opik_Dispatcher checks the flag before dispatch.
  • Async dispatch? Yes. Spans are queued; dispatch happens via prautoblogger_opik_dispatch cron (no blocking).
  • Cost logging? Unchanged. Your cost_tracker->log_api_call() remains separate from Opik; Opik is observability-only.

Example: minimal instrumentation

public function my_operation( $input ) {
	$model = get_option( 'my_model', 'default-model' );
	$ctx = PRAutoBlogger_Opik_Trace_Context::current();

	$span_id = $ctx->start_span(
		array(
			'name'     => 'my_operation',
			'type'     => 'llm',
			'model'    => $model,
			'provider' => 'openrouter',
		)
	);

	$response = $this->llm->send_chat_completion( /* ... */ );

	$ctx->end_span(
		$span_id,
		array(
			'usage' => array(
				'prompt_tokens'     => $response['prompt_tokens'],
				'completion_tokens' => $response['completion_tokens'],
				'total_tokens'      => $response['prompt_tokens'] + $response['completion_tokens'],
			),
		)
	);

	return $response['content'];
}

That's it. No config, no feature-flag checks, no try/catch needed.