mvanhorn/last30days-skill · error · SystemExit
[CompetitorsPlan] Top-level must be a dict of {entity: {targ
Error message
[CompetitorsPlan] Top-level must be a dict of {entity: {targeting}}, got {type(parsed).__name__}\n What it means
A structural guard in parse_competitors_plan(): valid JSON that parses to a list, string, number, or null is rejected because the plan must be a dict mapping entity names to targeting dicts. The message names the actual top-level type received. Inner entries that are not dicts are only warned-and-skipped; only the top level is fatal.
Source
Thrown at skills/last30days/scripts/last30days.py:814
plan_str = raw
if os.path.isfile(plan_str):
try:
with open(plan_str, encoding="utf-8") as f:
plan_str = f.read()
except (OSError, UnicodeDecodeError) as exc:
sys.stderr.write(f"[CompetitorsPlan] Cannot read plan file: {exc}\n")
raise SystemExit(2)
try:
parsed = json.loads(plan_str)
except json.JSONDecodeError as exc:
sys.stderr.write(f"[CompetitorsPlan] Invalid JSON: {exc}\n")
raise SystemExit(2)
if not isinstance(parsed, dict):
sys.stderr.write(
f"[CompetitorsPlan] Top-level must be a dict of "
f"{{entity: {{targeting}}}}, got {type(parsed).__name__}\n"
)
raise SystemExit(2)
known_fields = {
"x_handle", "x_related", "subreddits",
"github_user", "github_repos", "trustpilot_domain", "context",
}
normalized: dict[str, dict] = {}
for entity, entry in parsed.items():
if not isinstance(entry, dict):
sys.stderr.write(
f"[CompetitorsPlan] Entry for {entity!r} must be a dict, "
f"got {type(entry).__name__}; skipping.\n"
)
continue
unknown = set(entry.keys()) - known_fields
if unknown:
sys.stderr.write(
f"[CompetitorsPlan] Unknown fields in {entity!r}: "
f"{sorted(unknown)}; ignoring.\n"
)View on GitHub (pinned to c7460f6114)
Solutions
- Reshape the plan to a flat dict: {"Acme": {"x_handle": "acme"}, "Beta": {"subreddits": ["beta"]}}.
- Allowed targeting keys are enumerated just below the guard: x_handle, x_related, subreddits, github_user, github_repos, trustpilot_domain, context — use only these.
- If you just want entity names without targeting, use --competitors-list 'Acme,Beta' instead.
Example fix
# before
[{"entity": "Acme", "x_handle": "acme"}]
# after
{"Acme": {"x_handle": "acme"}} Defensive patterns
Strategy: type-guard
Validate before calling
parsed = json.loads(plan_str)
if not isinstance(parsed, dict) or not all(isinstance(v, dict) for v in parsed.values()):
raise ValueError('plan must be {entity: {targeting...}}, got a different shape') Type guard
def is_competitors_plan(value: Any) -> TypeGuard[dict[str, dict]]:
return isinstance(value, dict) and all(
isinstance(k, str) and isinstance(v, dict) for k, v in value.items()
) Prevention
- Use only the known targeting keys (x_handle, x_related, subreddits, github_user, github_repos, trustpilot_domain, context).
- For name-only lists, prefer --competitors-list over a plan file.
When it happens
Trigger: --competitors-plan '["Acme", "Beta"]' or a file containing a bare array of entities; a top-level {'entities': [...]} wrapper instead of the flat {entity: {targeting}} shape.
Common situations: Plan schemas from other tools (list-of-objects) fed in unchanged; LLM-generated plans wrapping entities under a key; confusion with --competitors-list which does take a comma string.
Related errors
- [CompetitorsPlan] Invalid JSON: {exc}\n
- {repo_dir.name} emitted the agent JSON profile; the evaluato
- [CompetitorsPlan] Cannot read plan file: {exc}\n
- [Competitors] --competitors-list is empty.\n
- [Competitors] --competitors must be >= {COMPETITORS_MIN} (go
AI-assisted analysis of mvanhorn/last30days-skill@c7460f6114 (2026-08-15).
Data as JSON: /api/errors/37e26c06aa8c47bd.
Report an issue: GitHub.