From da98436315b922dd08c3d11506edaf40588e129d Mon Sep 17 00:00:00 2001 From: egdev Date: Thu, 23 Jul 2026 13:02:42 +0200 Subject: [PATCH] feat(analytics): define social strategy ingestion --- ...ial-strategy-analytics-trends-ingestion.md | 116 ++++++ ...ategy-analytics-trends-ingestion.fake.yaml | 148 ++++++++ ...ial-strategy-analytics-trends-ingestion.sh | 345 ++++++++++++++++++ skills/linkedin-weekly-planner/SKILL.md | 27 +- skills/strategy-planner/SKILL.md | 28 +- skills/x-queue-planner/SKILL.md | 29 +- 6 files changed, 680 insertions(+), 13 deletions(-) create mode 100644 docs/operations/social-strategy-analytics-trends-ingestion.md create mode 100644 examples/social-strategy-analytics-trends-ingestion.fake.yaml create mode 100755 scripts/validate-social-strategy-analytics-trends-ingestion.sh diff --git a/docs/operations/social-strategy-analytics-trends-ingestion.md b/docs/operations/social-strategy-analytics-trends-ingestion.md new file mode 100644 index 0000000..8e99059 --- /dev/null +++ b/docs/operations/social-strategy-analytics-trends-ingestion.md @@ -0,0 +1,116 @@ +# Social strategy analytics and trends ingestion + +This contract defines a fake-first ingestion surface for social strategy planning. It builds on the content-ledger join model and the existing LinkedIn/X analytics snapshot contracts without claiming that live browser, API, or scraping ingestion exists. + +## Source option matrix + +| Source option | Reliability | Privacy exposure | ToS risk | Implementation effort | Approval status | +|---|---|---|---|---|---| +| Manual sanitized entry | medium | low when values are aggregated and demo-safe | low | low | approved for fake fixtures and human-entered sanitized snapshots | +| Native platform export | medium-high when exported by an authorized operator | medium; exports may contain account/private metrics before normalization | medium; depends on platform export terms | medium | requires separate approval before any real export is committed or processed | +| Approved provider/API | high when documented analytics endpoints exist | medium; credentials and account scopes must stay outside the repo | medium; provider terms must be reviewed | high | pending per-provider approval; not implemented here | +| RSS, web, search, or trend providers | medium; best for topic signals, not account analytics | medium; public sources still need summaries only | medium; source-specific usage limits apply | medium | allowed only as fake/demo normalized trend fixtures here | +| Browser scraping or browser automation | medium and brittle | high; pages may expose account data or private metrics | high | high | not approved; explicitly out of scope until separately reviewed | + +## Normalized snapshot schema + +Repository-facing strategy inputs must use normalized snapshots, not raw provider payloads. A social strategy ingestion fixture contains: + +```yaml +schema_version: 1 +contract: social-strategy-analytics-trends-ingestion +project: +fake: true +safe_for_repo: true +live_scraping_enabled: false +live_api_ingestion_enabled: false +browser_automation_enabled: false +raw_provider_payloads_included: false +screenshots_included: false +account_data_included: false +private_metrics_included: false +credential_material_included: false +source_options: + - option: + reliability: + privacy: + tos_risk: + implementation_effort: + approval_status: +normalized_snapshots: + - snapshot_id: + project: + network: + strategy_namespace_key: discord-project-manager/project//strategy + content_ledger_namespace_key: discord-project-manager/project//content-ledger + network_namespace_key: discord-project-manager/project//network/ + source_type: manual-sanitized-entry + source_approval_status: approved-for-fake-fixtures + captured_at: + snapshot_window: + start: + end: + metrics: + - content_ledger_entry_id: + planning_reference_id: + published_at: + values: + # LinkedIn values are exactly: + impressions: + reactions: + comments: + shares: + clicks: + engagement_rate: <0.0-1.0|unknown> + # X values are exactly: impressions, likes, replies, reposts, quote_posts, clicks, engagement_rate. + # Account-level/private metrics such as followers_gained are not allowed. +trend_signals: + - trend_id: + project: + strategy_namespace_key: discord-project-manager/project//strategy + network_scope: + - + source_type: + provenance: + source_kind: + retrieved_by: + retrieval_mode: fake-fixture-no-network + canonical_url: https://example.invalid/ + source_title: + observed_at: + confidence: <0.0-1.0> + signal_summary: +metadata: + fixture_type: fake-demo + safe_for_repo: true + note: +``` + +Required safety flags are top-level booleans. Repository fixtures for this contract must set `fake` and `safe_for_repo` to `true`, and must set `live_scraping_enabled`, `live_api_ingestion_enabled`, `browser_automation_enabled`, `raw_provider_payloads_included`, `screenshots_included`, `account_data_included`, `private_metrics_included`, and `credential_material_included` to `false`. + +Machine enum values required by the validator: + +- `source_options[].option`: `manual-sanitized-entry`, `native-platform-export`, `approved-provider-api`, `rss-web-search-trend-provider`, `browser-scraping-automation`. +- `source_options[].reliability`: `low`, `medium`, `medium-high`, `high`. +- `source_options[].privacy`, `source_options[].tos_risk`, `source_options[].implementation_effort`: `low`, `medium`, `high`. +- `source_options[].approval_status`: `approved-for-fake-fixtures`, `requires-separate-approval`, `pending-provider-approval`, `not-approved`. +- `normalized_snapshots[].network`: currently `linkedin` and `x` for the combined fixture. +- `normalized_snapshots[].source_type`: `manual-sanitized-entry` for repository fake analytics snapshots; trend providers stay under `trend_signals`, and native export/provider/API snapshots require a separate approved implementation before they can be validated here. +- `normalized_snapshots[].source_approval_status`: `approved-for-fake-fixtures`. +- `trend_signals[].source_type`: `rss`, `web`, `search`, `trend-provider`, `manual-sanitized-entry`. +- `trend_signals[].provenance.source_kind`: `rss-feed`, `webpage`, `search-result`, `trend-report`, `manual-note`. +- `trend_signals[].provenance.retrieval_mode`: `fake-fixture-no-network`. + +## Strategy consumption rules + +- Strategy skills consume `normalized_snapshots` and `trend_signals` after content-ledger normalization. +- Per-content analytics must join through `content_ledger_entry_id`; strategy output must not depend on provider-specific post IDs alone. +- Timestamps (`captured_at`, metric `published_at`, and trend `observed_at`) must be quoted RFC3339 UTC strings in `YYYY-MM-DDTHH:MM:SSZ` form; YAML timestamp scalars/datetime objects and space-separated timestamp strings are not accepted. +- Metric `published_at` must be on or before the snapshot `captured_at`, and its date must fall inside the inclusive `snapshot_window.start` through `snapshot_window.end` range. +- Trend inputs must preserve `source_type`, `provenance`, `observed_at`, and numeric `confidence` so planners can separate confirmed facts from weak signals. +- Count metrics (`impressions`, `reactions`, `comments`, `shares`, `clicks`, `likes`, `replies`, `reposts`, `quote_posts`) must be non-negative integers or `unknown`; `engagement_rate` must be numeric from `0.0` to `1.0` inclusive or `unknown`. +- Unknown metrics remain `unknown`; planners must not invent private metrics or infer account-level results from missing fields. + +## Non-goals and safety constraints + +This slice does not implement live scraping, browser automation, native export processing, OAuth, account analytics APIs, publishing, scheduling, or dashboard synchronization. Do not commit raw provider payloads, screenshots, account data, private metrics, credentials, or real source URLs. Browser/API scraping must not be described as live or production-ready until a separate implementation and approval path exists. diff --git a/examples/social-strategy-analytics-trends-ingestion.fake.yaml b/examples/social-strategy-analytics-trends-ingestion.fake.yaml new file mode 100644 index 0000000..6a6c2eb --- /dev/null +++ b/examples/social-strategy-analytics-trends-ingestion.fake.yaml @@ -0,0 +1,148 @@ +schema_version: 1 +contract: social-strategy-analytics-trends-ingestion +project: egdev +fake: true +safe_for_repo: true +live_scraping_enabled: false +live_api_ingestion_enabled: false +browser_automation_enabled: false +raw_provider_payloads_included: false +screenshots_included: false +account_data_included: false +private_metrics_included: false +credential_material_included: false +source_options: + - option: manual-sanitized-entry + reliability: medium + privacy: low + tos_risk: low + implementation_effort: low + approval_status: approved-for-fake-fixtures + - option: native-platform-export + reliability: medium-high + privacy: medium + tos_risk: medium + implementation_effort: medium + approval_status: requires-separate-approval + - option: approved-provider-api + reliability: high + privacy: medium + tos_risk: medium + implementation_effort: high + approval_status: pending-provider-approval + - option: rss-web-search-trend-provider + reliability: medium + privacy: medium + tos_risk: medium + implementation_effort: medium + approval_status: approved-for-fake-fixtures + - option: browser-scraping-automation + reliability: medium + privacy: high + tos_risk: high + implementation_effort: high + approval_status: not-approved +normalized_snapshots: + - snapshot_id: linkedin-normalized-2026-w23-demo + project: egdev + network: linkedin + strategy_namespace_key: discord-project-manager/project/egdev/strategy + content_ledger_namespace_key: discord-project-manager/project/egdev/content-ledger + network_namespace_key: discord-project-manager/project/egdev/network/linkedin + source_type: manual-sanitized-entry + source_approval_status: approved-for-fake-fixtures + captured_at: "2026-06-08T09:00:00Z" + snapshot_window: + start: 2026-06-01 + end: 2026-06-07 + metrics: + - content_ledger_entry_id: linkedin-post-001-demo + planning_reference_id: linkedin-weekly-post-01-demo + published_at: "2026-06-03T14:00:00Z" + values: + impressions: 420 + reactions: 18 + comments: 4 + shares: 2 + clicks: 11 + engagement_rate: 0.083 + - content_ledger_entry_id: linkedin-post-002-demo + planning_reference_id: linkedin-weekly-post-02-demo + published_at: "2026-06-05T16:30:00Z" + values: + impressions: 355 + reactions: 12 + comments: 3 + shares: 1 + clicks: unknown + engagement_rate: unknown + - snapshot_id: x-normalized-2026-w24-demo + project: egdev + network: x + strategy_namespace_key: discord-project-manager/project/egdev/strategy + content_ledger_namespace_key: discord-project-manager/project/egdev/content-ledger + network_namespace_key: discord-project-manager/project/egdev/network/x + source_type: manual-sanitized-entry + source_approval_status: approved-for-fake-fixtures + captured_at: "2026-06-16T09:15:00Z" + snapshot_window: + start: 2026-06-09 + end: 2026-06-15 + metrics: + - content_ledger_entry_id: x-post-001-demo + planning_reference_id: historical-ledger-entry + published_at: "2026-06-10T09:00:00Z" + values: + impressions: 610 + likes: 24 + replies: 5 + reposts: 3 + quote_posts: 1 + clicks: 14 + engagement_rate: 0.077 + - content_ledger_entry_id: x-post-002-demo + planning_reference_id: x-queue-item-02-demo + published_at: "2026-06-12T15:45:00Z" + values: + impressions: 455 + likes: 19 + replies: 2 + reposts: 2 + quote_posts: unknown + clicks: unknown + engagement_rate: unknown +trend_signals: + - trend_id: trend-linkedin-ops-proof-demo + project: egdev + strategy_namespace_key: discord-project-manager/project/egdev/strategy + network_scope: + - linkedin + source_type: web + provenance: + source_kind: webpage + retrieved_by: web-readability-fake-fixture + retrieval_mode: fake-fixture-no-network + canonical_url: https://example.invalid/trends/ops-proof + source_title: Demo trend brief about operational proof points + observed_at: "2026-06-08T10:00:00Z" + confidence: 0.72 + signal_summary: Demo-only signal that implementation proof points may outperform generic launch announcements. + - trend_id: trend-x-reviewability-demo + project: egdev + strategy_namespace_key: discord-project-manager/project/egdev/strategy + network_scope: + - x + source_type: rss + provenance: + source_kind: rss-feed + retrieved_by: fake-rss-fixture-adapter + retrieval_mode: fake-fixture-no-network + canonical_url: https://example.invalid/trends/reviewability-feed + source_title: Demo feed item about reviewable engineering work + observed_at: "2026-06-16T11:30:00Z" + confidence: 0.64 + signal_summary: Demo-only signal that short reviewability lessons can seed longer technical threads. +metadata: + fixture_type: fake-demo + safe_for_repo: true + note: This fixture contains sanitized fake analytics and trend signals only. diff --git a/scripts/validate-social-strategy-analytics-trends-ingestion.sh b/scripts/validate-social-strategy-analytics-trends-ingestion.sh new file mode 100755 index 0000000..8feba4e --- /dev/null +++ b/scripts/validate-social-strategy-analytics-trends-ingestion.sh @@ -0,0 +1,345 @@ +#!/usr/bin/env bash +set -euo pipefail + +FIXTURE_PATH="${SOCIAL_STRATEGY_ANALYTICS_TRENDS_FIXTURE:-examples/social-strategy-analytics-trends-ingestion.fake.yaml}" +DOC_PATH="docs/operations/social-strategy-analytics-trends-ingestion.md" +PROJECT_SLUG="egdev" +CONTENT_LEDGER_NAMESPACE="discord-project-manager/project/egdev/content-ledger" +STRATEGY_NAMESPACE="discord-project-manager/project/egdev/strategy" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +command -v grep >/dev/null 2>&1 || fail "required command not found: grep" +command -v python3 >/dev/null 2>&1 || fail "required command not found: python3" + +SKILL_PATHS=( + "skills/strategy-planner/SKILL.md" + "skills/linkedin-weekly-planner/SKILL.md" + "skills/x-queue-planner/SKILL.md" +) + +for path in "$FIXTURE_PATH" "$DOC_PATH" "${SKILL_PATHS[@]}"; do + [[ -f "$path" ]] || fail "required file not found: $path" +done + +python3 - "$FIXTURE_PATH" "$DOC_PATH" "$PROJECT_SLUG" "$CONTENT_LEDGER_NAMESPACE" "$STRATEGY_NAMESPACE" "${SKILL_PATHS[@]}" <<'PY' +import re +import sys +from datetime import date, datetime +from pathlib import Path + +SIGNAL_SUMMARY_MAX_CHARS = 280 + +fixture_path = Path(sys.argv[1]) +doc_path = Path(sys.argv[2]) +project_slug = sys.argv[3] +content_ledger_namespace = sys.argv[4] +strategy_namespace = sys.argv[5] +skill_paths = [Path(value) for value in sys.argv[6:]] + +try: + import yaml +except ImportError as exc: + raise SystemExit("ERROR: PyYAML is required for structural validation; install PyYAML and retry.") from exc + +class Loader(yaml.SafeLoader): + pass + +def mapping(loader, node, deep=False): + out = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in out: + raise ValueError(f"duplicate YAML key: {key}") + out[key] = loader.construct_object(value_node, deep=deep) + return out + +Loader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, mapping) + +def req(ok, msg): + if not ok: + raise ValueError(msg) + + +def req_keys(mapping_value, allowed, area): + req(isinstance(mapping_value, dict), f"{area} must be a mapping") + extra = set(mapping_value) - set(allowed) + missing = set(allowed) - set(mapping_value) + req(not extra, f"{area} contains unsupported keys: {', '.join(sorted(extra))}") + req(not missing, f"{area} is missing keys: {', '.join(sorted(missing))}") + +def parse_ts(value, field): + req( + isinstance(value, str) + and re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", value), + f"{field} must be an RFC3339 UTC timestamp string ending in Z", + ) + return datetime.fromisoformat(value.replace("Z", "+00:00")) + +def parse_date(value, field): + if isinstance(value, date) and not isinstance(value, datetime): + return value + req(isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value), f"{field} must be YYYY-MM-DD") + return date.fromisoformat(value) + +COUNT_METRICS_BY_NETWORK = { + "linkedin": {"impressions", "reactions", "comments", "shares", "clicks"}, + "x": {"impressions", "likes", "replies", "reposts", "quote_posts", "clicks"}, +} +RATE_METRICS = {"engagement_rate"} +REQUIRED_METRICS_BY_NETWORK = { + network: count_metrics | RATE_METRICS + for network, count_metrics in COUNT_METRICS_BY_NETWORK.items() +} + + +def validate_metric_value(network, key, value): + if value == "unknown": + return + req(not isinstance(value, bool), f"metric {key} must not be boolean") + if key in COUNT_METRICS_BY_NETWORK[network]: + req(isinstance(value, int), f"metric {key} must be a non-negative integer or unknown") + req(value >= 0, f"metric {key} must be a non-negative integer or unknown") + return + if key in RATE_METRICS: + req(isinstance(value, (int, float)), f"metric {key} must be numeric between 0 and 1 or unknown") + req(0 <= value <= 1, f"metric {key} must be numeric between 0 and 1 or unknown") + return + raise ValueError(f"unsupported metric key for {network}: {key}") + + +def req_rejects_metric(network, key, value): + try: + validate_metric_value(network, key, value) + except ValueError: + return + raise ValueError(f"validator self-check accepted invalid metric {network}.{key}: {value!r}") + + +for invalid_metric in [ + ("linkedin", "impressions", True), + ("linkedin", "impressions", 1.5), + ("x", "likes", -1), + ("x", "engagement_rate", False), + ("x", "engagement_rate", 2.5), +]: + req_rejects_metric(*invalid_metric) + +text = fixture_path.read_text() +doc = doc_path.read_text() +data = yaml.load(text, Loader=Loader) +req_keys(data, { + "schema_version", + "contract", + "project", + "fake", + "safe_for_repo", + "live_scraping_enabled", + "live_api_ingestion_enabled", + "browser_automation_enabled", + "raw_provider_payloads_included", + "screenshots_included", + "account_data_included", + "private_metrics_included", + "credential_material_included", + "source_options", + "normalized_snapshots", + "trend_signals", + "metadata", +}, "fixture") +req(data.get("schema_version") == 1, "schema_version must be 1") +req(data.get("contract") == "social-strategy-analytics-trends-ingestion", "wrong contract") +req(data.get("project") == project_slug, "project slug mismatch") +for flag in ["fake", "safe_for_repo"]: + req(data.get(flag) is True, f"{flag} must be true") +for flag in [ + "live_scraping_enabled", + "live_api_ingestion_enabled", + "browser_automation_enabled", + "raw_provider_payloads_included", + "screenshots_included", + "account_data_included", + "private_metrics_included", + "credential_material_included", +]: + req(data.get(flag) is False, f"{flag} must be false") + +expected_options = { + "manual-sanitized-entry", + "native-platform-export", + "approved-provider-api", + "rss-web-search-trend-provider", + "browser-scraping-automation", +} +expected_source_approval_status = { + "manual-sanitized-entry": "approved-for-fake-fixtures", + "native-platform-export": "requires-separate-approval", + "approved-provider-api": "pending-provider-approval", + "rss-web-search-trend-provider": "approved-for-fake-fixtures", + "browser-scraping-automation": "not-approved", +} +source_options = data.get("source_options") or [] +req(isinstance(source_options, list) and source_options, "source_options are required") +seen_options = set() +for option in source_options: + req_keys(option, {"option", "reliability", "privacy", "tos_risk", "implementation_effort", "approval_status"}, "source option") + name = option.get("option") + req(name in expected_options, f"unexpected source option: {name}") + req(name not in seen_options, f"duplicate source option: {name}") + seen_options.add(name) + req(option.get("reliability") in {"low", "medium", "medium-high", "high"}, f"{name} invalid reliability") + req(option.get("privacy") in {"low", "medium", "high"}, f"{name} invalid privacy") + req(option.get("tos_risk") in {"low", "medium", "high"}, f"{name} invalid ToS risk") + req(option.get("implementation_effort") in {"low", "medium", "high"}, f"{name} invalid effort") + req(option.get("approval_status") == expected_source_approval_status[name], f"{name} approval status must be {expected_source_approval_status[name]}") +req(seen_options == expected_options, "source option matrix incomplete") + +snapshots = data.get("normalized_snapshots") or [] +req(isinstance(snapshots, list) and len(snapshots) >= 2, "at least LinkedIn and X snapshots are required") +networks = set() +ledger_ids = set() +for snapshot in snapshots: + req_keys(snapshot, {"snapshot_id", "project", "network", "strategy_namespace_key", "content_ledger_namespace_key", "network_namespace_key", "source_type", "source_approval_status", "captured_at", "snapshot_window", "metrics"}, "normalized snapshot") + network = snapshot.get("network") + networks.add(network) + req(snapshot.get("project") == project_slug, "snapshot project mismatch") + req(snapshot.get("strategy_namespace_key") == strategy_namespace, "snapshot strategy namespace mismatch") + req(snapshot.get("content_ledger_namespace_key") == content_ledger_namespace, "snapshot content-ledger namespace mismatch") + req(snapshot.get("network_namespace_key") == f"discord-project-manager/project/{project_slug}/network/{network}", "snapshot network namespace mismatch") + req(snapshot.get("source_type") == "manual-sanitized-entry", "repository fake analytics snapshots must use manual sanitized entry") + req(snapshot.get("source_approval_status") == "approved-for-fake-fixtures", "repository fake snapshots must not use pending or separately approved sources") + captured_at = parse_ts(snapshot.get("captured_at"), "captured_at") + window = snapshot.get("snapshot_window") or {} + req_keys(window, {"start", "end"}, "snapshot_window") + window_start = parse_date(window.get("start"), "snapshot_window.start") + window_end = parse_date(window.get("end"), "snapshot_window.end") + req(window_start <= window_end, "snapshot_window.start must be on or before snapshot_window.end") + req(captured_at.date() >= window_end, "captured_at date must be on or after snapshot_window.end") + metrics = snapshot.get("metrics") or [] + req(metrics, "snapshot metrics are required") + for metric in metrics: + req_keys(metric, {"content_ledger_entry_id", "planning_reference_id", "published_at", "values"}, "metric") + ledger_id = metric.get("content_ledger_entry_id") + req(isinstance(ledger_id, str) and ledger_id.endswith("-demo"), "metrics must join through safe demo content_ledger_entry_id") + ledger_ids.add(ledger_id) + req(metric.get("planning_reference_id"), "planning_reference_id required") + published_at = parse_ts(metric.get("published_at"), "metric.published_at") + req(published_at <= captured_at, "metric.published_at must be on or before captured_at") + req(window_start <= published_at.date() <= window_end, "metric.published_at date must be within snapshot_window") + values = metric.get("values") or {} + required_metric_keys = REQUIRED_METRICS_BY_NETWORK.get(network) + req(required_metric_keys, f"unsupported normalized snapshot network: {network}") + actual_metric_keys = set(values) + req(actual_metric_keys == required_metric_keys, f"{network} metrics must be exactly: {', '.join(sorted(required_metric_keys))}") + for key, value in values.items(): + validate_metric_value(network, key, value) +req({"linkedin", "x"}.issubset(networks), "normalized snapshots must include LinkedIn and X") +req({"linkedin-post-001-demo", "x-post-001-demo"}.issubset(ledger_ids), "expected ledger demo joins missing") + +trend_signals = data.get("trend_signals") or [] +req(isinstance(trend_signals, list) and trend_signals, "trend_signals are required") +for trend in trend_signals: + req_keys(trend, {"trend_id", "project", "strategy_namespace_key", "network_scope", "source_type", "provenance", "observed_at", "confidence", "signal_summary"}, "trend signal") + req(trend.get("project") == project_slug, "trend project mismatch") + req(trend.get("strategy_namespace_key") == strategy_namespace, "trend strategy namespace mismatch") + req(trend.get("source_type") in {"rss", "web", "search", "trend-provider", "manual-sanitized-entry"}, "invalid trend source_type") + parse_ts(trend.get("observed_at"), "trend.observed_at") + confidence = trend.get("confidence") + req(not isinstance(confidence, bool) and isinstance(confidence, (int, float)) and 0 <= confidence <= 1, "trend confidence must be numeric between 0 and 1") + req(trend.get("network_scope") and set(trend.get("network_scope")).issubset({"linkedin", "x"}), "trend network_scope invalid") + req(trend.get("signal_summary") and len(trend.get("signal_summary")) <= SIGNAL_SUMMARY_MAX_CHARS, "trend signal_summary must be bounded") + prov = trend.get("provenance") or {} + req_keys(prov, {"source_kind", "retrieved_by", "retrieval_mode", "canonical_url", "source_title"}, "trend provenance") + for field in ["source_kind", "retrieved_by", "retrieval_mode", "canonical_url", "source_title"]: + req(prov.get(field), f"trend provenance missing {field}") + req(prov.get("source_kind") in {"rss-feed", "webpage", "search-result", "trend-report", "manual-note"}, "invalid provenance source_kind") + req(prov.get("retrieval_mode") == "fake-fixture-no-network", "trend retrieval must be fake/no-network") + req(str(prov.get("canonical_url", "")).startswith("https://example.invalid/"), "trend canonical_url must be example.invalid") + +metadata = data.get("metadata") or {} +req_keys(metadata, {"fixture_type", "safe_for_repo", "note"}, "metadata") +req(metadata.get("fixture_type") == "fake-demo", "metadata.fixture_type must be fake-demo") +req(metadata.get("safe_for_repo") is True, "metadata.safe_for_repo must be true") + +unsafe_patterns = [ + r"\b[0-9]{17,20}\b", + r"BUFFER_[A-Z0-9_]+", + r"DISCORD_[A-Z0-9_]+", + r"OPENAI_[A-Z0-9_]+", + r"ANTHROPIC_[A-Z0-9_]+", + r"GITHUB_TOKEN", + r"ENGRAM_[A-Z0-9_]+", + r"sk-[A-Za-z0-9_]+", + r"gh[pousr]_[A-Za-z0-9_]+", + r"xox[baprs]-[A-Za-z0-9_-]+", + r"https?://(localhost|127\.0\.0\.1|discord\.com|notion\.so|api\.|www\.)", + r"/home/|/Users/", + r"production-ready|live fetching passed|live scraping passed|live API validated|browser automation validated|uses production credentials", + r"live scraping is enabled|live api ingestion is enabled|browser automation is enabled|uses live scraping|uses live api ingestion|uses browser automation", +] +unsafe_pattern_self_checks = [ + (r"\b[0-9]{17,20}\b", "123456789" + "012345678"), + (r"BUFFER_[A-Z0-9_]+", "BUFFER_TOKEN"), + (r"https?://(localhost|127\.0\.0\.1|discord\.com|notion\.so|api\.|www\.)", "https://discord" + ".com/channels/123"), + (r"production-ready|live fetching passed|live scraping passed|live API validated|browser automation validated|uses production credentials", "live scraping passed"), + (r"live scraping is enabled|live api ingestion is enabled|browser automation is enabled|uses live scraping|uses live api ingestion|uses browser automation", "live scraping is enabled"), +] +for pattern, sample in unsafe_pattern_self_checks: + req(re.search(pattern, sample, re.IGNORECASE), f"unsafe pattern self-check failed: {pattern}") +for pattern in unsafe_patterns: + req(not re.search(pattern, text, re.IGNORECASE), f"unsafe fixture marker matched: {pattern}") +for url in re.findall(r"https?://[^\s<>\'\")\]}]+", text): + req(url.startswith("https://example.invalid/"), f"non-demo fixture URL found: {url}") + +for marker in [ + "Source option matrix", + "Normalized snapshot schema", + "Strategy skills consume", + "not implement live scraping", + "not approved", + "content_ledger_entry_id", + "provenance", + "confidence", +]: + req(marker in doc, f"doc missing marker: {marker}") + +for skill_path in skill_paths: + skill = skill_path.read_text() + for marker in [ + "normalized_snapshots", + "trend_signals", + "content_ledger_entry_id", + "unknown", + "provenance", + "confidence", + "Do not consume raw scraped pages", + "Do not claim live browser/API scraping", + ]: + req(marker in skill, f"{skill_path} missing skill contract marker: {marker}") + +print("PyYAML schema validation passed.") +PY + +for required in \ + "contract: social-strategy-analytics-trends-ingestion" \ + "fake: true" \ + "safe_for_repo: true" \ + "live_scraping_enabled: false" \ + "browser_automation_enabled: false" \ + "network: linkedin" \ + "network: x" \ + "content_ledger_entry_id: linkedin-post-001-demo" \ + "content_ledger_entry_id: x-post-001-demo" \ + "source_type: web" \ + "source_type: rss" \ + "retrieval_mode: fake-fixture-no-network"; do + grep -F "$required" "$FIXTURE_PATH" >/dev/null || fail "fixture missing marker: $required" +done + +echo "Validated fake social strategy analytics/trends ingestion contract." +echo "Fixture: $FIXTURE_PATH" +echo "Doc: $DOC_PATH" +echo "Project: $PROJECT_SLUG" diff --git a/skills/linkedin-weekly-planner/SKILL.md b/skills/linkedin-weekly-planner/SKILL.md index af94167..f321ab8 100644 --- a/skills/linkedin-weekly-planner/SKILL.md +++ b/skills/linkedin-weekly-planner/SKILL.md @@ -26,6 +26,8 @@ Optional inputs: - `brand_context_summary` - `recent_ledger_summary` +- `normalized_snapshots` for `network: linkedin` +- `trend_signals` scoped to LinkedIn or cross-network strategy - `weekly_theme` - `audience_focus` - `review_preferences` @@ -36,10 +38,11 @@ Optional inputs: 1. Start from approved brand context and recent ledger context when they are available. 2. Keep weekly planning separate from cross-network strategy rules. 3. Separate confirmed facts, assumptions, explicit `missing_context`, and proposed post angles. -4. Keep each weekly post idea small enough for human review. -5. Treat memory writes as planned targets until a human approves them. -6. Use only fake/demo values in repository-facing examples. -7. Follow ADR 0002 for all namespace references. +4. If analytics or trends are supplied, use only sanitized normalized snapshots and trend signals from the shared `docs/operations/social-strategy-analytics-trends-ingestion.md` contract; join LinkedIn metrics through `content_ledger_entry_id`, keep unknown metrics as `unknown`, and include trend provenance, observed timestamp, confidence, and source type in the planning basis. +5. Keep each weekly post idea small enough for human review. +6. Treat memory writes as planned targets until a human approves them. +7. Use only fake/demo values in repository-facing examples. +8. Follow ADR 0002 for all namespace references. ## Output shape @@ -72,6 +75,20 @@ planning_basis: - missing_context: - + sanitized_performance_inputs: + - content_ledger_entry_id: + insight: + trend_inputs: + - source_type: + observed_at: + confidence: <0.0-1.0> + provenance: + source_kind: + retrieved_by: + retrieval_mode: fake-fixture-no-network + canonical_url: https://example.invalid/ + source_title: + strategy_signal: proposed_angles: - weekly_plan: @@ -136,6 +153,8 @@ Promote reusable LinkedIn planning rules, approval checkpoints, and contract cha ## Safety rules - Do not claim memory was written unless the runtime actually saved it after approval. +- Do not consume raw scraped pages, screenshots, native exports, provider payloads, account data, credentials, or private metrics; require sanitized normalized snapshots instead. +- Do not claim live browser/API scraping, analytics ingestion, or trend-provider access until a separate approved implementation exists. - Do not publish or schedule content from this contract alone. - Do not put durable LinkedIn planning under runtime Discord namespaces. - Do not include private brand plans, secrets, or real customer data in repo examples. diff --git a/skills/strategy-planner/SKILL.md b/skills/strategy-planner/SKILL.md index 7da92a6..d2309ba 100644 --- a/skills/strategy-planner/SKILL.md +++ b/skills/strategy-planner/SKILL.md @@ -25,6 +25,8 @@ Optional inputs: - `campaign_name` - `recent_ledger_summary` +- `normalized_snapshots` from `social-strategy-analytics-trends-ingestion` +- `trend_signals` from `social-strategy-analytics-trends-ingestion` - `known_assets` - `review_preferences` @@ -33,10 +35,11 @@ Optional inputs: 1. Start from approved brand context when available. 2. Separate confirmed facts from assumptions. 3. Produce concise planning structures instead of long prose. -4. Put cross-network planning rules in project strategy memory and network-local planning state in the network subtree. -5. Keep execution steps for publishing, analytics, and Discord operations out of scope. -6. Use only fake/demo values in repository-facing examples. -7. Follow ADR 0002 for all namespace references. +4. When analytics or trends are provided, consume only sanitized normalized snapshots and trend signals from the shared `docs/operations/social-strategy-analytics-trends-ingestion.md` contract; join analytics through `content_ledger_entry_id`, keep unknown metrics as `unknown`, and preserve trend provenance, timestamp, confidence, and source type in the planning basis. +5. Put cross-network planning rules in project strategy memory and network-local planning state in the network subtree. +6. Keep execution steps for publishing, analytics, and Discord operations out of scope. +7. Use only fake/demo values in repository-facing examples. +8. Follow ADR 0002 for all namespace references. ## Output shape @@ -51,6 +54,21 @@ strategy_slice: - assumptions: - + sanitized_performance_inputs: + - content_ledger_entry_id: + network: + insight: + trend_inputs: + - source_type: + observed_at: + confidence: <0.0-1.0> + provenance: + source_kind: + retrieved_by: + retrieval_mode: fake-fixture-no-network + canonical_url: https://example.invalid/ + source_title: + strategy_signal: planned_items: - title: purpose: @@ -101,6 +119,8 @@ Promote reusable planning rules, review checkpoints, and skill behavior into rep ## Safety rules - Do not pretend memory writes, Discord routing, or Buffer analytics are already operational. +- Do not consume raw scraped pages, screenshots, native exports, provider payloads, account data, credentials, or private metrics; require sanitized normalized snapshots instead. +- Do not claim live browser/API scraping, analytics ingestion, or trend-provider access until a separate approved implementation exists. - Do not publish or schedule content from this contract alone. - Do not put durable cross-network strategy inside runtime Discord namespaces. - Keep repository examples generic and fake. diff --git a/skills/x-queue-planner/SKILL.md b/skills/x-queue-planner/SKILL.md index b2cad79..508b0f6 100644 --- a/skills/x-queue-planner/SKILL.md +++ b/skills/x-queue-planner/SKILL.md @@ -27,6 +27,8 @@ Optional inputs: - `brand_context_summary` - `recent_ledger_summary` +- `normalized_snapshots` for `network: x` +- `trend_signals` scoped to X or cross-network strategy - `queue_goal` - `review_preferences` - `source_context` @@ -38,11 +40,12 @@ Optional inputs: 2. Treat source ingestion as a normalization step that produces proposal-only queue candidates, never a durable write. 3. Keep X queue planning separate from cross-network strategy rules. 4. Separate confirmed facts, assumptions, variation rules, and proposed angles or candidate updates. -5. Keep each queued item small enough for human review before any drafting or publishing. -6. Treat memory writes as planned targets until a human approves the queue. -7. For write-like Discord requests, load `skills/discord-approval-gate/SKILL.md` and ask for explicit approval before any persistence. -8. Use only fake/demo values in repository-facing examples. -9. Follow ADR 0002 for all namespace references. +5. If analytics or trends are supplied, use only sanitized normalized snapshots and trend signals from the shared `docs/operations/social-strategy-analytics-trends-ingestion.md` contract; join X metrics through `content_ledger_entry_id`, keep unknown metrics as `unknown`, and include trend provenance, observed timestamp, confidence, and source type in the planning basis. +6. Keep each queued item small enough for human review before any drafting or publishing. +7. Treat memory writes as planned targets until a human approves the queue. +8. For write-like Discord requests, load `skills/discord-approval-gate/SKILL.md` and ask for explicit approval before any persistence. +9. Use only fake/demo values in repository-facing examples. +10. Follow ADR 0002 for all namespace references. ## Output shape @@ -81,6 +84,20 @@ planning_basis: - variation_rules: - + sanitized_performance_inputs: + - content_ledger_entry_id: + insight: + trend_inputs: + - source_type: + observed_at: + confidence: <0.0-1.0> + provenance: + source_kind: + retrieved_by: + retrieval_mode: fake-fixture-no-network + canonical_url: https://example.invalid/ + source_title: + strategy_signal: proposed_angles: - queue_plan: @@ -152,6 +169,8 @@ Promote reusable X queue planning rules, approval checkpoints, and contract chan ## Safety rules - Do not claim memory was written unless the runtime actually saved it after approval. +- Do not consume raw scraped pages, screenshots, native exports, provider payloads, account data, credentials, or private metrics; require sanitized normalized snapshots instead. +- Do not claim live browser/API scraping, analytics ingestion, or trend-provider access until a separate approved implementation exists. - Do not publish or schedule content from this contract alone. - Do not put durable X queue planning under runtime Discord namespaces. - Do not include private brand plans, secrets, or real customer data in repo examples.