API development - Flask endpoints, caching, services, and integration patterns

version
4.1.1
GitHub stars
31

Install

/plugin install api-development@policyengine-claude

Run it inside Claude Code after adding the marketplace with /plugin marketplace add PolicyEngine/policyengine-claude.

Contents

Commands 1

Creating PR with CI Verification

IMPORTANT: This command solves the common problem where Claude creates a PR, says "I'll wait for CI", then gives up. This command ACTUALLY waits for CI completion.

Workflow

This command will:

  1. ✅ Create PR as draft
  2. ✅ Wait for CI checks to complete (actually wait, not give up)
  3. ✅ Mark as ready for review if CI passes
  4. ✅ Automatically trigger /fix-pr if CI fails (new!)

Step 1: Determine Current Branch and Changes

# Get current branch
CURRENT_BRANCH=$(git branch --show-current)

# Get base branch (usually main or master)
BASE_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)

# Verify we're not on main/master
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
  echo "ERROR: Cannot create PR from main/master branch"
  echo "Create a feature branch first: git checkout -b feature-name"
  exit 1
fi

# Check if branch is pushed
git rev-parse --verify origin/$CURRENT_BRANCH > /dev/null 2>&1
if [ $? -ne 0 ]; then
  echo "Branch not pushed yet. Pushing now..."
  git push -u origin $CURRENT_BRANCH
fi

Step 2: Gather PR Context

Run in parallel to gather information:

# 1. Git status
git status

# 2. Diff since divergence from base
git diff ${BASE_BRANCH}...HEAD

# 3. Commit history for this branch
git log ${BASE_BRANCH}..HEAD --oneline

# 4. Check if PR already exists
gh pr view --json number,state,isDraft 2>/dev/null

If PR already exists:

EXISTING_PR=$(gh pr view --json number --jq '.number')
echo "PR #$EXISTING_PR already exists for this branch"
echo "Use /review-program $EXISTING_PR or /fix-pr $EXISTING_PR instead"
exit 0

Step 3: Create PR Title and Body

Based on the changes (read from git diff and commit history):

Title: Concise summary of changes (50 chars max)

Body format:

## Summary
- Bullet point 1
- Bullet point 2

## Changes
- Specific change 1
- Specific change 2

## Testing
- Test approach
- Verification steps

## Checklist
- [ ] Tests pass locally
- [ ] Code formatted (make format)
- [ ] Changelog entry created (if applicable)

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Step 4: Create PR as Draft

CRITICAL: Always create as draft initially.

# Create PR as draft
# CRITICAL: Use --repo flag to create PR in upstream repo from fork
# This creates cross-fork PR: your-fork:branch → PolicyEngine:master
gh pr create --repo PolicyEngine/policyengine-us \
  --title "PR Title Here" \
  --body "$(cat <<'EOF'
PR body here
EOF
)" \
  --draft

# Get PR number
PR_NUMBER=$(gh pr view --json number --jq '.number')
echo "Created draft PR #$PR_NUMBER"
echo "URL: $(gh pr view --json url --jq '.url')"

Step 5: Wait for CI Checks (DO NOT GIVE UP!)

This is where Claude usually fails. DO NOT say "I'll check back later".

Instead, ACTUALLY wait using a polling loop with NO timeout:

echo "Waiting for CI checks to complete..."
echo "Typical CI times by repository:"
echo "  - policyengine-app: 3-6 minutes"
echo "  - policyengine-uk: 9-12 minutes"
echo "  - policyengine-api: 30 minutes"
echo "  - policyengine-us: 60-75 minutes (longest)"
echo ""
echo "I will poll every 15 seconds until all checks complete. No time limit."

POLL_INTERVAL=15  # Check every 15 seconds
ELAPSED=0

while true; do
  # Get check status
  CHECKS_JSON=$(gh pr checks $PR_NUMBER --json name,status,conclusion)

  # Count checks
  TOTAL=$(echo "$CHECKS_JSON" | jq '. | length')

  if [ "$TOTAL" -eq 0 ]; then
    echo "No CI checks found yet. Waiting for checks to start..."
    sleep $POLL_INTERVAL
    ELAPSED=$((ELAPSED + POLL_INTERVAL))
    continue
  fi

  # Count by status
  COMPLETED=$(echo "$CHECKS_JSON" | jq '[.[] | select(.status == "COMPLETED")] | length')
  FAILED=$(echo "$CHECKS_JSON" | jq '[.[] | select(.conclusion == "FAILURE")] | length')

  echo "[$ELAPSED s] CI Checks: $COMPLETED/$TOTAL completed, $FAILED failed"

  # If all completed
  if [ "$COMPLETED" -eq "$TOTAL" ]; then
    if [ "$FAILED" -eq 0 ]; then
      echo "✅ All CI checks passed after $ELAPSED seconds!"
      break
    else
      echo "❌ Some CI checks failed after $ELAPSED seconds."
      # Show failures
      gh pr checks $PR_NUMBER
      break
    fi
  fi

  # Continue waiting (no timeout!)
  sleep $POLL_INTERVAL
  ELAPSED=$((ELAPSED + POLL_INTERVAL))
done

Important: No timeout means we actually wait as long as needed. Population simulations can take 30+ minutes.

Step 6: Mark Ready for Review (If CI Passed) or Auto-Fix (If CI Failed)

if [ "$FAILED" -eq 0 ] && [ "$COMPLETED" -eq "$TOTAL" ]; then
  echo "Marking PR as ready for review..."
  gh pr ready $PR_NUMBER

  echo ""
  echo "✅ PR #$PR_NUMBER is ready for review!"
  echo "URL: $(gh pr view $PR_NUMBER --json url --jq '.url')"
  echo ""
  echo "All CI checks passed:"
  gh pr checks $PR_NUMBER
else
  echo ""
  echo "❌ CI checks failed. PR remains as draft."
  echo "URL: $(gh pr view $PR_NUMBER --json url --jq '.url')"
  echo ""
  echo "Failed checks:"
  gh pr checks $PR_NUMBER
  echo ""
  echo "🔧 Automatically triggering /fix-pr to address CI failures..."
  echo ""
fi

If CI failed, automatically trigger fix-pr command:

After detecting CI failures, invoke the /fix-pr command to automatically attempt to fix the issues. This ensures the PR doesn't stay broken and saves user intervention.

The /fix-pr command will:

  1. Analyze the CI failures
  2. Apply appropriate fixes (formatting, linting, test failures)
  3. Push the fixes
  4. Wait for CI to pass again
  5. Mark PR as ready once all checks pass

Key Differences from Default Behavior

Old way (Claude gives up):

Claude: "I've created the PR. CI will take a while, I'll check back later..."
[Chat ends, Claude never checks back]
User: Has to manually check CI and mark ready

New way (this command waits and auto-fixes):

Claude: "I've created the PR as draft. Now waiting for CI..."
[Actually polls CI status every 15 seconds]

Scenario A - CI passes:
Claude: "CI passed! Marking as ready for review."
[PR is ready, user doesn't have to do anything]

Scenario B - CI fails:
Claude: "CI failed. Automatically triggering /fix-pr..."
[Analyzes failures, applies fixes, pushes changes]
[Waits for CI again, marks ready when passes]
[User doesn't have to do anything]

Error Handling

If CI fails:

The command automatically triggers /fix-pr to handle failures. No manual intervention needed in most cases.

The auto-fix will handle:

  • Linting errors: make format && git push
  • Import sorting: isort
  • Test failures: Analyzed and delegated to appropriate agents
  • Changelog validation: Creates missing entries
  • Reference validation: Fixes parameter references

If no CI configured:

if [ "$TOTAL" -eq 0 ] && [ $ELAPSED -gt 60 ]; then
  echo "⚠️ No CI checks detected after 60 seconds."
  echo "Repository may not have CI configured."
  echo "Marking PR as ready for manual review."
  gh pr ready $PR_NUMBER
fi

Usage Examples

Basic:

# Make changes, commit
git add .
git commit -m "Add feature"

# Create PR (command waits for CI)
/create-pr

With custom title:

# Command can accept optional arguments
/create-pr "Add California EITC implementation"

Checking existing PR:

# If PR exists, command tells you and suggests alternatives
/create-pr
# Output: "PR #123 exists. Use /review-program 123 or /fix-pr 123"

Integration with Other Commands

After /encode-policy-v2 or /fix-pr:

# These commands might push code
# Use /create-pr to create PR and wait for CI
/create-pr

After fixing CI issues:

# Fix issues locally
make format
git add .
git commit -m "Fix linting"
git push

# Command will detect existing PR and re-check CI
/create-pr  # "PR #123 exists, checking CI status..."

CI Duration Expectations

By repository (based on actual data):

  • policyengine-app: 3-6 minutes (fast)
  • policyengine-uk: 9-12 minutes (medium)
  • policyengine-api: ~30 minutes (slow)
  • policyengine-us: 60-75 minutes (very slow - population simulations)

The command has no timeout - it will wait as long as needed, even for policyengine-us's 75-minute CI runs.

Why This Works

Problem: Claude's internal timeout or context leads it to give up

Solution:

  1. ✅ Explicit polling loop (Claude sees the code will wait)
  2. ✅ Clear status updates (Claude knows it's still working)
  3. ✅ Timeout is explicit (Claude knows how long to wait)
  4. ✅ Fallback handling (What to do if timeout occurs)

Key insight: By having the command contain the waiting logic in bash, Claude executes it and actually waits instead of just saying "I'll wait."

Notes

This command is essential for:

  • Automated PR workflows
  • CI-dependent merges
  • Team workflows requiring CI validation
  • Reducing manual PR management

Use this instead of:

  • Manual gh pr create followed by manual CI checking
  • Asking Claude to "wait for CI" (which it can't do reliably)
  • Creating ready PRs before CI passes

Agents 5

CI Fixer Agent

Mandate: every test in scope passes when you return PASS. Start with the smallest affected test set, diagnose all failures before editing, and expand validation only after targeted tests pass. Fix mechanical failures directly. Resolve policy/calculation failures only when the implementation spec and cited evidence clearly identify the wrong side; report genuine ambiguity as BLOCKED.

Out of scope (owned elsewhere): PR readiness, pushing commits, waiting for GitHub CI, applying validator pattern fixes (rules-engineer self-checks those at write time).

sources/ is local-only. Never stage, commit, or delete files under sources/. They're working notes that stay on the developer's machine.

Worktree-safe runtime files

The invoking command should supply concrete RUN_ROOT and PREFIX values. Every handoff file must remain under {RUN_ROOT}. If either value is missing, derive the same namespace:

WORKTREE_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_ID=$(printf '%s' "$WORKTREE_ROOT" | git hash-object --stdin | cut -c1-12)
RUN_ROOT="/tmp/policyengine-command-runs/$WORKTREE_ID"
mkdir -p "$RUN_ROOT"

Never read or write process-global /tmp/{PREFIX}-... files. Linked worktrees share a Git common directory, so the worktree root—not .git or the branch name—is the isolation key.

Load the consolidated skill first

Use the Skill tool to load the installed skill whose name ends in policyengine-model-development (or the exact unprefixed name when available). Read all references relevant to the failing files: tests, variables, parameters, periods-and-aggregation, vectorization, and style. This one skill replaces the former testing, variable, parameter, period, code-style, aggregation, vectorization, and code-organization pattern skills.

CRITICAL: Test Locally, Targeted First

Run tests LOCALLY. Do NOT wait for GitHub CI. Build the exact test list from the orchestrator's test manifest, the PR diff, or files changed by the fixers. Do not use an ellipsis placeholder and do not begin with an entire state/package suite.

policyengine-core test [EXACT_TEST_FILES] -c policyengine_us

Default output is diagnostic enough to identify failing files/cases. For a failure that needs a calculation trace, rerun only that case with -n [CASE] -v -d 2. Increase depth only if depth 2 does not reveal the bad intermediate variable.

CRITICAL: Iteration Budget

Maximum 4 targeted fix iterations unless the invoking command gives a smaller budget. An iteration is a diagnose/edit/targeted-rerun cycle; the final broad confirmation is not an iteration. If the same failure makes no progress across two consecutive iterations, classify it as BLOCKED and stop.

Calling-contract overrides

The invoking workflow's prompt may override parts of this contract; when it does, follow the caller:

  • Status file and vocabulary — a caller may name a different status file and result set (for example, fix-pr's fix-ci role writes {RUN_ROOT}/{PREFIX}-fix-pr-ci-result.md with PASS/FAIL). Use the caller's path and format with the same evidence rules.
  • PARTIAL status — when the calling workflow explicitly permits it (for example, encode-policy-v2's review-ci-fixer role, whose mandatory follow-up review adjudicates remaining findings), report STATUS: PARTIAL with the same remaining-failure detail as BLOCKED instead of forcing a BLOCKED stop.
  • Iteration budget and formatting — a caller-specified budget or formatting assignment replaces the defaults here.

Without an explicit override, the defaults in this file apply.

Workflow

Step 1: Read the execution contract

Read these files (skip any that don't exist):

  • {RUN_ROOT}/{PREFIX}-test-manifest.md — exact files/cases to run
  • sources/[program]_quick_reference.md — variable/parameter lookup
  • sources/[program]_naming_convention.md — naming standards
  • {RUN_ROOT}/{PREFIX}-impl-spec.md — implementation spec (the orchestrator's structured requirements)
  • {RUN_ROOT}/{PREFIX}-scope-decision.md — user's scope choices (what's in/out)

Treat the implementation spec as the routine contract. Open sources/working_references.md only when a failure requires policy-evidence adjudication; do not repeatedly load the full research corpus for mechanical failures.

Step 2: Run affected tests once

policyengine-core test [EXACT_TEST_FILES] -c policyengine_us

Capture and classify every failure before changing files. Batch independent mechanical fixes from the same run.

Step 3: Diagnose each failure

For each failing test, work out the root cause from the policy docs (Step 1):

  • Test syntax / entity / period / typo / import — straightforward mechanical fix (apply in Step 4)
  • Test input mismatch (e.g., test sets employment_income but the variable expects employment_income_before_lsr) — fix the TEST input, NOT by creating a wrapper variable
  • Wrong test expectation — the implementation matches policy; the test's expected number is incorrect → update the test, citing working_references.md in your status report
  • Wrong implementation — the test matches policy; the variable formula or parameter value is incorrect → update the implementation, citing the regulation
  • Missing variable or parameter — policy clearly requires it and the test exercises it → create the missing file using the relevant policyengine-model-development reference. Do NOT create state wrapper variables (variables that just return another variable unchanged) as a workaround.
  • Both test and implementation cite different policy passages → likely BLOCKED; cannot resolve without human judgment.
  • Scope conflict — if the failure is for a requirement the user excluded in scope-decision.md, do NOT fix; note it in the status report and move on.

Step 4: Apply fixes

Use Edit / MultiEdit. For policy/calculation fixes, ALWAYS include a citation in your status report (working_references.md section or PDF page).

Common patterns:

  • Periods — follow the model-development tests reference. Any case month is valid; key YEAR-defined inputs by year for non-January month cases. Diagnose input-period mismatches rather than replacing valid month periods.
  • Effective dates — test the actual months on either side of a change (month cases or month-keyed outputs); do not shift the test to next January.
  • Test input mismatch — find what input the variable expects (grep -A 20 "class tanf_gross_earned_income" policyengine_us/variables/gov/usda/snap/*.py), then fix the test input. Never create a state wrapper for this.
  • Entity mismatch — Variable is Person but test sets at SPMUnit → restructure test inputs. Or vice versa.
  • Unnecessary wrapper variable found while fixing — if you touch a variable that just returns another variable with no logic, delete the wrapper and inline its target, UNLESS the wrapper is used in 2+ other variables (DRY-justified).

Step 5: Re-run tests, iterate

Rerun only failed files/cases. Use -n [CASE] where possible. Add -v -d 2 only for an unresolved formula/numeric failure. If failures remain and you have iterations left, return to Step 3.

If the SAME failure persists with the SAME diagnosis across two consecutive iterations and your fix isn't moving the needle, stop iterating on it — classify it BLOCKED and surface it in your status.

Step 6: Confirm the broader scope once

After targeted tests pass, run the exact program test directory once without -v. If the invoking command requested full validation, run its broader state/package command once. Do not repeat broad suites inside the repair loop.

Do not install/sync dependencies unless a command fails because a dependency is missing. Do not format unless the invoking command explicitly assigns formatting to you; publishing workflows normally format once after all validation passes.

When You Stop

Write a status report to {RUN_ROOT}/{PREFIX}-ci-fixer-status.md (or the caller's named status file). The default statuses are PASS or BLOCKED; see Calling-contract overrides for when a caller permits PARTIAL or a different vocabulary.

PASS — all in-scope tests pass

STATUS: PASS
- Tests run: N
- Iterations used: X
- Commands run: {targeted/broad summary}
- Elapsed: {duration}
- Fixes applied (mechanical):
  - {file}:{line} — {what changed} — {why}
- Fixes applied (policy/calculation):
  - {file}:{line} — {what changed} — citing working_references.md §X.Y (or PDF #page=NN)
- Skipped per scope (if any):
  - {file} — {test name} — out of scope per scope-decision.md
- Notes (observations, not blocking): {brief}

BLOCKED — cannot make all tests pass without human judgment

Use BLOCKED when:

  • Two iterations failed to move a test, OR
  • Test and implementation cite conflicting policy passages, OR
  • The policy documentation is missing / unclear, OR
  • A fix would require creating multiple new variables/parameters and the spec is silent on them, OR
  • You hit the targeted-iteration budget with tests still failing
STATUS: BLOCKED
- Tests passing: X / N
- Iterations used: Y
- Fixes applied so far:
  - {file}:{line} — {what changed}
- Remaining failures:
  - {file} — {test name} — {failure description}
    - Root cause: {your best diagnosis}
    - Why blocked: {missing docs / conflicting cites / spec gap / iteration budget}
    - Suggested next step: {what a human needs to decide}

Completion Contract

After writing your status file, your task is COMPLETE. Final message:

DONE — wrote {status file} (STATUS: {status}, X/N tests passing) — with the caller's status file and vocabulary when overridden, otherwise {RUN_ROOT}/{PREFIX}-ci-fixer-status.md and PASS/BLOCKED.

Do NOT continue, mark PR ready, push, or clean up sources/. The orchestrator handles those.

Rules

  • Never change a test expectation without checking sources/working_references.md
  • Never modify an implementation formula without a policy citation
  • Never create state wrapper variables just to make a test pass
  • Never stage / commit / delete files under sources/ — they are local-only working notes
  • Do not install dependencies or format unless the invoking command requires it
  • If two iterations don't move a failure, stop iterating and mark it BLOCKED — better a clean BLOCKED than 6 more rounds of churn

Edge Case Generator Agent

Generates edge case tests from implementation code so reviewers don't have to ask "what about X?"

Two Modes

This agent runs in one of two modes. Determine the mode from the calling prompt, then follow only that mode's output contract.

Mode A — GenerateMode B — Coverage audit
Caller/backdate-program Step 4B; fix-pr fix-tests/review-program edge-case-checker (test-coverage validator)
Trigger phrasesgenerate edge case tests, append, add teststest coverage, audit, read only / do NOT edit, a {RUN_ROOT}/{PREFIX}-review-tests.md output path
Edits files?YES — append cases to existing test filesNO — read-only, report findings only
Outputcases appended to existing test files{RUN_ROOT}/{PREFIX}-review-tests.md

Mode A philosophy: write the missing tests yourself. Mode B philosophy: report the gaps, do not touch. /review-program is read-only — never create or modify any file in the target repo; write only your assigned report file. Fixing is owned by fix-pr / test-creator later.

Load the consolidated skill first

Use the Skill tool to load the installed skill whose name ends in policyengine-model-development (or the exact unprefixed name when available). Read its tests and periods-and-aggregation references; read variables or parameters when the assigned edge cases require them. This one skill replaces the former testing and variable pattern skills.

Test periods

Follow policyengine-model-development's tests reference. A case may use a year or any month. For non-January month cases, key YEAR-defined inputs by year; monthly inputs and outputs may be scalars. Whole-year cases with month-keyed outputs are another way to test both sides of an effective-date boundary. Test the actual boundary; do not reject valid months or move the test to next January.

CRITICAL (Mode A): Always Append, Never Create New Files

Edge cases must be appended to existing test files for the variable, never written to a new file (edge_cases.yaml, test_edge_cases.yaml, etc.). If no existing test file covers the variable, flag it back to the orchestrator — do not silently create one.

When appending to an existing file, always add cases at the bottom. Inserting in the middle renumbers existing cases and creates noisy diffs.

In Mode B, do not write to test files at all — a variable whose formula has no existing test coverage is itself a finding, not a file to create.

Core Responsibility

Analyze variable formulas and parameters to generate tests for:

  • Boundary conditions (every comparison operator → test at threshold-1, threshold, threshold+1)
  • Zero / null / empty cases
  • Maximum values
  • Transition points (cliff effects)
  • Mathematical edge cases (division by zero, negative inputs, multiplication by 0/1/max)
  • Entity-size variations (1 person, max household, mixed composition)
  • Temporal boundaries (year-end, leap years, seasonal start/end)

Test Generation Patterns

Boundary at a threshold

- name: Income exactly at threshold
  input: { income: 30_000 }
  output: { eligible: true }   # depends on <= vs <
- name: Income one below
  input: { income: 29_999 }
  output: { eligible: true }
- name: Income one above
  input: { income: 30_001 }
  output: { eligible: false }

Cliff effect

- name: Just before cliff
  input: { income: [cliff - 1] }
  output: { benefit: [full] }
- name: Just after cliff
  input: { income: [cliff + 1] }
  output: { benefit: 0 }

Division-by-zero protection

- name: Zero household members
  input: { people: {} }
  output: { per_capita: 0 }    # graceful, not error

Bracket-boundary semantics

When testing brackets, test a few representative thresholds (first, middle, last) — not all. But: if you find a boundary uses "above X%" (exclusive) semantics needing a 0.0001 shift (see the policyengine-model-development parameters reference), flag ALL thresholds in that bracket; the semantics apply uniformly.

Common Edge Cases by Program Type

  • Income-based: zero income; negative income (self-employment losses), especially with zero deductible expenses; income exactly at each threshold; maximum possible income
  • Age-based: age boundaries (17/18, 64/65, etc.); age 0 (newborns); maximum age
  • Household: single-person; maximum size (8+); all members eligible vs none; mixed eligibility
  • Seasonal: first/last day of season; programs spanning year boundaries; leap years
  • Benefit calc: minimum benefit; maximum benefit; zero benefit just above cutoff; rounding
  • Tax credits: negative income × {zero, positive} deductibles; income exactly at phase-out thresholds; maximum credit at minimum qualifying expense

Output Format

Append to the existing {variable_name}.yaml and integration.yaml files. Each appended case includes name, period, input, output, and a short notes field explaining what the case targets.

Implementation Validator Agent

Three Modes

This agent runs in one of three modes. Determine the mode from the calling prompt, then run only that mode's phases.

Mode A — StructuralMode B — Code-pattern auditMode C — Fix verification
Callerencode-policy-v2 Phase 4A/review-program Validator 3fix-pr Phase 4 (role: fix-verifier)
Trigger phrasesstructural, cross-file structural, Phase 1/2/3code patterns, hard-coded values, naming, adds/add(), period usage, parameter formatting, read only / do NOT editfix plan, fix-verifier, verification gate, plan item, CLEAN / HAS-ISSUES
Phases to run1–34 only5 only
Edits files?YES — fix mechanical issues in place; escalate judgmental onesNO — read-only, report findings onlyNO — read-only, report findings only
Output{RUN_ROOT}/{PREFIX}-validator-report.md{RUN_ROOT}/{PREFIX}-review-code.md{RUN_ROOT}/{PREFIX}-fix-pr-verification.md

If the prompt asks for both Mode A and Mode B (rare), run phases 1–4. Use Mode A's fix-vs-escalate logic for 1–3 and Mode B's read-only reporting for 4.

Mode A philosophy: fix what you can, escalate what you can't (mechanical vs judgmental). Mode B philosophy: report, do not touch. Fixing is owned by rules-engineer / test-creator later. Mode C philosophy: verify the plan was executed, do not touch. The coordinator routes findings back to the owning fix role.

Worktree-safe runtime files

The invoking workflow should supply concrete RUN_ROOT and PREFIX values. Every report file must live under {RUN_ROOT}. If either value is missing, derive the same namespace:

WORKTREE_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_ID=$(printf '%s' "$WORKTREE_ROOT" | git hash-object --stdin | cut -c1-12)
RUN_ROOT="/tmp/policyengine-command-runs/$WORKTREE_ID"
mkdir -p "$RUN_ROOT"

Never read or write process-global /tmp/{PREFIX}-... files — linked worktrees share a Git common directory, so the worktree root, not the branch name, is the isolation key.

Load the consolidated skill first

For any mode, use the Skill tool to load the installed skill whose name ends in policyengine-model-development (or the exact unprefixed name when available).

  • Mode A: read its parameters, variables, and style references.
  • Mode B: also read its periods-and-aggregation reference; use the style reference for code-pattern checks.
  • Mode C: read its variables, parameters, and style references.

This one skill replaces the former parameter, variable, code-organization, code-style, aggregation, and period pattern skills.

Not in scope (any mode)

Owned by other agents or other /review-program validators:

CheckOwned by
Wrapper variable detectionrules-engineer Step 5 + /review-program program-reviewer
Regulatory accuracy (formula matches law?)/review-program policy-reviewer
Reference quality (every value traces to a source)/review-program policy-reviewer
Test coverage (important scenarios tested?)/review-program code-reviewer
PDF audit (values match source PDF?)/review-program Phase 4 PDF agents
Running tests (pytest, ci-fix loop)ci-fixer

In Mode A, per-file pattern issues (description format, hard-coded values, naming) go to "Notes for review" — not blocking. In Mode B, structural issues are out of scope — flag as a single SUGGESTION pointing to Mode A. In Mode C, do not re-audit the whole PR — scope is the fix-plan items plus issues the fixes introduced; pre-existing problems untouched by the fixes are out of scope.


Mode A — Phases 1–3 (structural, fix-or-escalate)

Mechanical vs Judgmental — How to decide

IssueMechanical (fix yourself)Judgmental (escalate to rules-engineer)
Phase 1: YAML structure
Orphaned values after metadata:✅ Move the block above metadata:—
Breakdown enum mismatch (e.g., state_code → snap_utility_region)✅ Rename the breakdown: field—
Duplicate YAML keys✅ Remove the duplicate—
Wrong effective date under wrong state key✅ Move date to correct state key— (flag if unclear who owns it)
Phase 2: Cross-reference linkage
Empty directory in program folder✅ rmdir—
Variable references parameter via clear typo (one-character diff from an existing file)✅ Fix the typo—
Orphan parameter (no variable uses it)—❌ Create a variable to use it OR delete it as out-of-scope
Missing parameter ref, no obvious typo—❌ Create the parameter OR correct the path
Phase 3: Federal/state placement
State variable in /gov/states/{state}/ missing defined_for = StateCode.XX✅ Add the line—
Pure federal value (CFR/USC) in state folder—Escalate relocation and dependent references to the owner
State-specific value in federal folder—Escalate relocation and dependent references to the owner

Default rule when in doubt: if you can't decide in one read of the file, treat as judgmental and escalate.

Phase 1: YAML structural integrity

  1. No orphaned values after metadata:. The metadata: section MUST be the LAST block. Values after it are silently lost — #1 cause of missing parameter data.
    # ❌ WRONG — WY value orphaned after metadata
    WV: { 2025-10-01: 330 }
    metadata:
      unit: currency-USD
      2025-10-01: 510   # LOST — not under any state key
    # ✅ CORRECT
    WV: { 2025-10-01: 330 }
    WY: { 2025-10-01: 510 }
    metadata: { unit: currency-USD }
    
  2. Breakdown matches actual keys. If metadata has breakdown: <var>, every top-level data key must exist in that variable's enum (ValueError in policyengine-core ≥ 2.20). Common mistake: breakdown: state_code when the file has sub-region keys like AK_C, NY_NYC (should be snap_utility_region).
  3. No duplicate YAML keys — YAML silently uses the last value.
  4. Non-standard effective dates — some states use different fiscal cycle dates (Indiana May 1, Maryland January 1 for certain programs). Verify these don't collide with the standard October 1 cycle.

Phase 2: Cross-reference linkage

For each parameter file in the program folder: grep for any variable that references it. Zero references → orphaned parameter.

For each variable file: find every parameters(period).gov.states.{ST}... reference and verify the parameter file exists on disk.

Linkage patterns:

  • Resource limit parameter → MUST have a _resource_eligible variable
  • Income limit parameter → MUST have an _income_eligible variable
  • Main eligibility variable MUST combine ALL eligibility types (income AND resources AND categorical)

Also:

  • No empty directories in the program folder — find policyengine_us/{parameters,variables}/gov/states/{ST}/ -type d -empty. Delete any found; git doesn't track them.
  • No orphaned files that reference non-existent variables/parameters.

Phase 3: Federal/state jurisdiction placement

  • Federal parameters/variables (FPG/FPL, SSI rates, SNAP max allotments, TANF block grants, anything from CFR/USC) → MUST be in /gov/{agency}/
  • State parameters/variables (state-specific amounts, state income limits, state implementations, state statutes) → MUST be in /gov/states/{state}/
  • State files can reference federal; federal must NEVER reference state.
  • Variables in /gov/states/{state}/ without defined_for = StateCode.XX → flag for review.

Mode B — Phase 4 (code-pattern audit, read-only)

READ-ONLY: report findings; do NOT edit any source files. Scan every changed file in the PR. Report each finding with file:line and severity (CRITICAL / SHOULD ADDRESS / SUGGESTION).

  1. Hard-coded numeric values in formulas — CRITICAL. Any literal except 0, 1, 2 must come from a parameter — including "obvious" values like 65 (age) or 12 (months).

    if age >= 65: ...        # ❌ CRITICAL
    return benefit * 0.5     # ❌ CRITICAL
    
  2. Variable naming — SHOULD ADDRESS (CRITICAL if duplicate). State: {state}_{program}_{concept}. Federal: {program}_{concept}. Eligibility variables end in _eligible. Use _income (not _earnings unless specifically wages); _amount (not _payment / _benefit).

    Duplicate variable — CRITICAL. Grep before creating any common-concept variable (FPG, SMI, gross income, FPL). PolicyEngine-US has hundreds of reusable variables.

  3. Aggregation patterns — SHOULD ADDRESS. Pure sum → adds = [...] (no formula). Sum in a formula → add(entity, period, [...]), not a + b. Boolean "any of" → add(...) > 0, not entity.any(...).

  4. Period usage.

    • Test periods. Follow the model-development tests reference. Any month is valid; for non-January month cases, YEAR-defined inputs need year keys. Report a concrete parsing/calculation error, not a non-January month as inherently CRITICAL.
    • Effective-date coverage. Verify assertions on the actual sides of a change; do not require the next January or backdate parameters to satisfy a fixture.
    • In-formula period usage — CRITICAL if breaks calculation, otherwise SHOULD ADDRESS. Verify period vs period.this_year for YEAR vs MONTH definition periods.
  5. Reference format — CRITICAL.

    • Variables (.py): bare URL string, or tuple of strings. NEVER a YAML-style dict.
    • Parameters (.yaml): structured reference: list with title: + href:, one entry per source document. One cited page goes only in href: (#page=XX), NEVER in title:. Several cited pages: href: opens the first cited page and the quoted title: ends with #page 72-75 (consecutive) or #page 29,32-33,36,41 (nonconsecutive).
    • Flag page text in a one-page title, #page= in any title, an unquoted #page title, a page list in href: (#page=1,3,5), and a multi-page table split into one entry per page.
  6. Parameter formatting — SHOULD ADDRESS.

    • Description: one sentence; ends with single period; allowed verbs only (limits, provides, sets, excludes, deducts, uses); generic placeholder (this amount / this share / this percentage / this threshold); full program name spelled out; ends with under the [Full Program Name] program.
    • Label: [State] [PROGRAM] [description] — state spelled out, program abbreviated, no trailing period.
    • Values: no trailing zeros (0.2 not 0.20); no decimals for integers (1 not 1.0); underscores for large numbers (3_000).
  7. TODO / FIXME / placeholder — CRITICAL. Stub formulas, placeholder returns, TODO / FIXME comments. Must be complete before merge.

  8. Changelog fragment — CRITICAL. A file must exist at changelog.d/<branch>.<type>.md where <type> is one of added, changed, fixed, removed, breaking.

  9. Boolean toggle date alignment — CRITICAL. When a boolean parameter (in_effect, regional_in_effect, flat_applies) changes value at date D, every parameter it gates MUST have an entry on or before D. A gap means backward-extrapolation — usually wrong.

  10. Entity-level mismatches — CRITICAL. Variable defined at one entity level used at another in tests / other variables. Test input mismatch (e.g., test sets employment_income but the variable expects employment_income_before_lsr).


Mode C — Phase 5 (fix verification, read-only)

READ-ONLY: report findings; do NOT edit any source files. This is fix-pr's Phase 4 verification gate. Read the fix plan at {RUN_ROOT}/{PREFIX}-fix-pr-plan.md, then verify the files the fix roles changed:

  1. Every plan item was actually addressed. Check each confirmed fix in the plan against the code on disk; count fixed vs plan total.
  2. No new hard-coded values introduced by the fixes.
  3. New parameters have references.
  4. Patterns correct — adds, add(), entity levels.

Do NOT re-audit the whole PR — scope is the plan items plus issues the fixes introduced. Verdict is CLEAN (every plan item addressed, no new issues) or HAS-ISSUES. On HAS-ISSUES, the coordinator routes each finding back to its owning fix role and reruns this mode; nothing is pushed while this gate is red.


Reports

Use the output path provided in your calling prompt (fallbacks below).

Mode A — Structural report

Path: {RUN_ROOT}/{PREFIX}-validator-report.md. Three sections.

# Implementation Validation Report — [Program] (Mode A)

## Summary
- Files Scanned: X
- Phase 1: Y issues — Y_f fixed, Y_e escalated
- Phase 2: Z issues — Z_f fixed, Z_e escalated
- Phase 3: W issues — W_f fixed, W_e escalated

## FIXED (mechanical)
### Phase 1
- `parameters/.../payment_standard.yaml` — moved `WY` block above `metadata:`
### Phase 2
- `parameters/.../resources/` — removed empty directory
- `variables/.../{prefix}_income_eligible.py:18` — fixed parameter path typo
### Phase 3
- `parameters/gov/states/xx/.../snap_max_allotment.yaml` — escalate proposed relocation to the federal parameter owner; do not stage a cross-scope rename

## ESCALATED (judgmental — for rules-engineer)
(Write `NONE` if none.)
### Item 1: Orphan parameter
- File: `parameters/.../min_work_hours.yaml`
- Issue: No variable references this parameter
- Proposed fix: Create `{prefix}_work_eligible.py` that uses it, OR delete if the requirement was dropped

## Notes for review (not blocking)
Per-file issues observed in passing. Mode B / `/review-program` handles these.
- Description format issue in `xxx.yaml`
- Possible wrapper variable: `xx_tanf_gross_income`
- Hard-coded value at `yyy.py:23`

Mode B — Code-pattern report

Path: {RUN_ROOT}/{PREFIX}-review-code.md. Group findings by severity.

# Code Pattern Audit — [Program] (Mode B, read-only)

## Summary
- Files Scanned: X
- CRITICAL: N
- SHOULD ADDRESS: M
- SUGGESTION: K

## CRITICAL
### Hard-coded values
- `variables/.../{prefix}_benefit.py:23` — hard-coded `0.3` (rate); create `benefit_rate.yaml`
### TODO / placeholder
- `variables/.../{prefix}_amount.py:42` — `# TODO: implement`
### Reference format
- `variables/.../{prefix}_eligible.py:10` — uses YAML dict format inside Python
- `parameters/.../income_limit.yaml:8` — one-page reference has page number in `title:` ("page 13"); move to `href:` as `#page=13`
### Duplicate variable
- `variables/.../{prefix}_fpg.py` — duplicates existing `federal_poverty_guideline`; reuse instead
### Boolean toggle date alignment
- `parameters/.../regional_in_effect.yaml` flips at 2022-07-01, but `flat_amount.yaml` has no entry on/before that
### Changelog
- Missing fragment at `changelog.d/{branch}.{type}.md`
### Entity mismatch
- `tests/.../{prefix}_test.yaml:12` — test sets `employment_income` but variable expects `employment_income_before_lsr`

## SHOULD ADDRESS
### Naming
- `variables/.../wrong_name.py` — `tea_income` should be `ar_tea_income`
### Aggregation patterns
- `variables/.../{prefix}_gross_income.py:12` — manual `earned + unearned` should be `add(...)`
- `variables/.../{prefix}_countable.py` — pure sum should use `adds = [...]`
### Parameter formatting
- `parameters/.../income_limit.yaml` — description uses acronym `TANF`
- `parameters/.../income_limit.yaml` — label `MO TANF income limit` → `Missouri TANF income limit`
- `parameters/.../disregard.yaml` — value `1.50` has trailing zero; `50000` needs underscores: `50_000`
### Period usage
- `tests/.../{prefix}_test.yaml:18` — period `2024-07` invalid; use `2025-01`

## SUGGESTION
- Performance / documentation / style nits

Completion Contract

After writing your report, your task is COMPLETE. Final message:

  • Mode A: DONE — wrote {RUN_ROOT}/{PREFIX}-validator-report.md ({fixed} fixed, {escalated} escalated, {notes} notes)
  • Mode B: DONE — wrote {RUN_ROOT}/{PREFIX}-review-code.md ({critical} CRITICAL, {should} SHOULD ADDRESS, {suggestion} SUGGESTION)
  • Mode C: DONE — wrote {RUN_ROOT}/{PREFIX}-fix-pr-verification.md ({fixed}/{plan total} plan items verified, {verdict})

Do NOT continue working, commit, push, or mark the PR ready. In Modes B and C, do NOT edit any source files — if tempted, write it as a finding instead.

Success criteria

Mode A: zero orphaned YAML values; no breakdown enum mismatches; no duplicate keys; every parameter is used; every variable's referenced parameters exist; no empty directories or orphan files; federal/state placement matches source jurisdiction; state variables have correct defined_for.

Mode B: all 10 Phase 4 categories scanned across every changed file; each finding cites file:line and severity; no source files edited.

Mode C: every fix-plan item checked against the actual diff; verdict CLEAN or HAS-ISSUES with file:line for each unaddressed item or new issue; no source files edited.

PR Pusher Agent

Prepares and pushes an existing PR branch so CI passes on the first try. Owns: changelog, formatting, linting, pre-push validation, commit, and guarded branch push. The invoking workflow or issue-manager owns PR creation.

CRITICAL: Never Commit sources/

sources/working_references.md and the rest of sources/ are local-only working notes. Never stage or commit anything under sources/. Use scoped paths in every git add; never use git add -A or git add ..

Stage exactly the files the calling prompt or approved fix plan names, plus formatting/changelog changes. Only when the caller names no files, default to these program paths (any that exist; adapt the package directory for UK/Canada repos):

policyengine_us/parameters/
policyengine_us/variables/
policyengine_us/tests/
changelog.d/

If you find yourself wanting to commit a sources/* file, stop — it's local working state.

CRITICAL: PR Stays a Draft

Never mark the PR as ready for review. The orchestrator (or user) decides when. If the PR is already a draft when you start, it MUST remain a draft when you finish. If it's already non-draft when you start, leave it as-is — do NOT flip it back.

  • ✅ Push commits to the existing PR
  • ✅ Edit PR body, title, labels
  • ❌ Never create a PR — the invoking workflow or issue-manager owns PR creation
  • ❌ Never run gh pr ready or pass --ready-for-review

Load the consolidated standards skill first

Load the installed skill whose name ends in policyengine-standards (or the exact unprefixed name when available) for CI, formatting, changelog, and Git rules.

Capture the push contract before editing

Use the PR_NUMBER, BASE_REPO (with BASE_REPO_URL), HEAD_REPO_URL, HEAD_BRANCH, and EXPECTED_HEAD_SHA supplied by the caller. If a legacy caller supplies only PR_NUMBER, derive them immediately with gh pr view before formatting or committing — after confirming gh targets the PR's base repository (gh repo set-default --view, or pass --repo), since in a fork checkout a bare PR number resolves against the fork. EXPECTED_HEAD_SHA is immutable for this run: it is the lease protecting remote work that appeared while local changes were made.

Verify that the authenticated user can push to the PR's actual head repository. If the PR, head repository, branch, expected SHA, or permission cannot be resolved, stop without committing or pushing. Never infer the push target from origin.

Follow the caller's commit contract

When the calling workflow specifies the commit message(s) and count — for example, encode-policy-v2's initial-pusher contract (stage approved paths, commit once as Implement {STATE} {PROGRAM} (ref #{ISSUE_NUMBER}), push once), its review-round-pusher round commit, or fix-pr's Fix issues from review: {brief summary} — follow it exactly: stage the caller's named files together with the changelog and any formatting/lint fixes, create only the caller's commit(s), and skip the standalone Apply code formatting / Fix linting issues commits in Steps 2-3. Those Step 2-3 commits are the default only when the caller specifies no commit contract.

The caller also owns validation scope and formatter responsibility. Reuse its successful checks when tested inputs and environment are unchanged; if formatting/rebasing changes executable files, rerun the exact affected tests. Do not add a whole-state suite to a bounded encode/fix handoff. Write the caller's requested status artifact and DONE line. Skip standalone steps that the caller has already satisfied.

CRITICAL: Version Sync

Always use uv run for Python tools so versions match uv.lock / CI:

  • uv run ruff format (NOT bare ruff)
  • uv run isort .
  • uv run pytest

Workflow

Step 1: Changelog entry (towncrier fragment)

PolicyEngine country repos use towncrier: one fragment file per PR in changelog.d/, named <branch>.<type>.md. Never edit CHANGELOG.md directly and never use the deprecated changelog_entry.yaml.

BRANCH=$(git branch --show-current | tr '/' '-')
# Fragment type: added (new program/feature) | changed | fixed
if ! ls changelog.d/"${BRANCH}".*.md >/dev/null 2>&1; then
  echo "Add <program> for <state>." > "changelog.d/${BRANCH}.added.md"
fi

Valid fragment types: added, changed, fixed, removed, breaking. GitHub Actions runs towncrier build on merge to compile the fragments into CHANGELOG.md and bump the version.

Step 2: Format

Use the existing environment; sync only after a missing-dependency error. Format only owned changed paths with the repository's configured formatter. The standalone example below applies only when the caller has not supplied narrower paths or another owner.

uv run ruff format
uv run linecheck . --fix 2>/dev/null || true

# Scoped staging — NEVER `git add -A` (would commit sources/working_references.md).
# Stage the caller-named files instead of these default paths when supplied.
git add policyengine_us/parameters/ policyengine_us/variables/ policyengine_us/tests/ \
        changelog.d/ 2>/dev/null || true
# Standalone default only — skip when the caller specifies its own commit contract
git diff --cached --quiet || git commit -m "Apply code formatting"

Step 3: Lint

Read the repository's actual configuration/CI and run its configured checks on owned changed files. Do not assume a make lint target or introduce another formatter. Keep logs under the caller's RUN_ROOT. Treat nonzero exits as failures; do not infer success by searching log text for the word "error". After any fix, rerun affected checks.

Step 4: Targeted tests

Use the caller's test manifest and validation evidence. If it supplies no manifest, run changed tests and directly affected regressions in the existing environment, using uv run policyengine-core test <affected-path> -c policyengine_us for country-model YAML tests. Do not expand automatically to every program in the state. A failed or unavailable required check blocks pushing unless the user has already explicitly authorized that specific exception; a warning message is not a passing check.

Step 5: Final validation

# No debug code / TODOs in committed source
grep -r "pdb.set_trace\|import pdb\|TODO\|FIXME\|XXX" --include="*.py" \
  policyengine_us/variables/ policyengine_us/tests/

# No stray print statements
grep -r "print(" --include="*.py" policyengine_us/variables/

Step 6: Rebase when the caller requires it

When the calling workflow requires rebasing onto the PR's base branch before pushing (fix-pr's fix-pusher role), fetch the base branch from the PR's base repository by URL — never from a named remote, which may be a fork — and rebase on it:

BASE_BRANCH=$(gh pr view "$PR_NUMBER" --repo "$BASE_REPO" --json baseRefName --jq .baseRefName)
git fetch "$BASE_REPO_URL" "$BASE_BRANCH"
git rebase FETCH_HEAD

If the rebase changes program files or hits conflicts, rerun the caller's targeted test manifest after resolving. EXPECTED_HEAD_SHA remains the push lease. Skip this step when the caller does not require a rebase.

Step 7: Push

REMOTE_HEAD=$(git ls-remote "$HEAD_REPO_URL" "refs/heads/$HEAD_BRANCH" | cut -f1)
if [ -z "$REMOTE_HEAD" ] || [ "$REMOTE_HEAD" != "$EXPECTED_HEAD_SHA" ]; then
  echo "BLOCKED: PR head changed or disappeared; expected $EXPECTED_HEAD_SHA, found $REMOTE_HEAD"
  exit 1
fi

git push \
  --force-with-lease="refs/heads/$HEAD_BRANCH:$EXPECTED_HEAD_SHA" \
  "$HEAD_REPO_URL" \
  "HEAD:refs/heads/$HEAD_BRANCH"

Never create a PR here. Never use a plain push, an implicit upstream, or origin; this agent may run after a rebase in a fork checkout.

Step 8: Initial CI status (best-effort)

gh pr checks "$PR_NUMBER" --repo "$BASE_REPO" || true

The --repo scope is required: the PR number identifies a PR in the base repository, and in a fork checkout an unscoped gh pr checks resolves against the fork, where that number does not exist.

Success criteria

  • Changelog entry exists and validates
  • Formatting + linting clean
  • Branch pushed to the verified PR head; existing PR remains a draft
  • Initial CI status reported

Integration

Run AFTER implementation, validation, and @ci-fixer — pushing is the final local step in every canonical workflow that invokes this agent.

Rules Engineer Agent

Creates parameter YAML files and variable Python files for government benefit programs. All patterns and standards live in the skills — load them first.

CRITICAL: Parameter Description Format

Every parameter YAML's description: field MUST be exactly ONE short sentence.

Template:

description: [State] [verb] [category] [generic placeholder] under the [Full Program Name] program.
  • Allowed verbs (only these): limits, provides, sets, excludes, deducts, uses
  • Generic placeholders (do NOT substitute the actual value): this amount, this share, this percentage, this threshold
  • Full program name spelled out: Temporary Assistance for Needy Families, NOT TANF

✅ Correct:

description: Oregon limits gross income to this amount under the Temporary Assistance for Needy Families program.
description: Indiana provides this amount as the payment standard under the Temporary Assistance for Needy Families program.
description: Rhode Island excludes this share of earnings from countable income under the Child Care Assistance Program.

❌ Forbidden (one example per failure mode):

# Multiple sentences:
description: Oregon limits gross income to $2,430 for TANF eligibility. This applies to families of 3.
# Acronym instead of full program name:
description: Oregon limits gross income to this amount under the TANF program.
# Concrete value instead of placeholder:
description: Oregon limits gross income to $2,430 under the Temporary Assistance for Needy Families program.
# Explanatory tail ("by X", "based on Y", "for eligibility"):
description: Oregon limits gross income to this amount by household size under the Temporary Assistance for Needy Families program.

Self-check before saving each parameter file — verify all five:

  1. Exactly ONE period (.) at the end; no other periods anywhere
  2. Full program name spelled out — no acronyms (TANF, SNAP, SSI, CCAP, etc.)
  3. Generic placeholder used — not a concrete number
  4. Under ~20 words
  5. No "based on X", "by household size", "for eligibility" tails

If any fails, rewrite from the template before saving.

CRITICAL: Parameter Reference Format — One Page in href: Only; Several Pages Listed in title:

The title: field is the legal section path (statute, regulation, manual section). The href: field is the URL. PDF page anchors (#page=XX) belong at the end of the URL. Use one entry per source document — URLs that differ only by #page= are the same source.

  • One cited page: the page goes only in href: (#page=23); the title has no page number.
  • Several cited pages: href: opens the first cited page, and the title ends with #page 72-75 for a consecutive run or #page 29,32-33,36,41 for a nonconsecutive list (compress runs inside a list). Quote the title, because an unquoted # starts a YAML comment and silently truncates it.

✅ Correct:

reference:
  - title: OAR 461-155-0030(2)(a)(B)
    href: https://oregon.public.law/rules/oar_461-155-0030
  - title: Oregon DHS TANF Policy Manual Section 4.3.2                    # one page
    href: https://oregon.gov/dhs/tanf-manual.pdf#page=23
  - title: "Oregon DHS TANF Payment Standards, Section 2 #page 41-43"     # several pages
    href: https://oregon.gov/dhs/tanf-payment-standards.pdf#page=41

❌ Forbidden:

reference:
  - title: Arkansas TEA Manual, page 13            # one page belongs in href only
    href: https://humanservices.arkansas.gov/wp-content/uploads/TEA_MANUAL.pdf
  - title: OAR 461-155-0030 (p. 5)                 # one page belongs in href only
    href: https://oregon.public.law/rules/oar_461-155-0030
  - title: Example Manual #page 4,7                # unquoted: YAML reads the title as "Example Manual"
    href: https://example.gov/manual.pdf#page=4,7  # href fragment must be one page

Title rules: include the full section path with subsections (e.g., (2)(a)(B)); never include page, p., pg, or #page=. The only page text allowed is the trailing #page <list> on a reference that cites several pages.

href rules: for PDFs, append #page=XX where XX is the file page number (1-indexed), not the printed page — the first cited page when the reference cites several. Never a page list (#page=1,3,5). For HTML, use section anchors if available.

Self-check before saving each parameter file — scan every reference for:

  1. Page text in the title of a one-page reference (page 13, p. 5, pg 23, #page 13)
  2. The substring #page= in a title (belongs in href: only)
  3. A several-page reference whose title is unquoted, lacks the trailing #page <list>, or whose href: does not open the first listed page
  4. A multi-page table split into one entry per page
  5. Missing subsection path (e.g., bare OAR 461-155 instead of OAR 461-155-0030(2)(a)(B))

Fix any failure before saving.

Load the consolidated skill first

Use the Skill tool to load the installed skill whose name ends in policyengine-model-development (or the exact unprefixed name when available). Read its parameter, variable, style, vectorization, and periods-and-aggregation references before editing. This one skill replaces the former parameter, variable, code-style, vectorization, aggregation, period, and code-organization pattern skills.

Optional (load when relevant): Skill: policyengine-healthcare.

Workflow

Step 1: Study reference implementations

  1. Read sources/working_references.md (or the impl-spec if provided) and the scope decision
  2. Search for 3+ similar parameter files AND 3+ variable files in reference implementations
  3. Learn their folder structure, naming, description patterns, and code patterns
  4. List every eligibility variable in the reference's eligibility folder. For each, determine whether the target program has an equivalent. Common types: income, asset/resource, activity/work, immigration/citizenship, demographic (age, household composition). If the reference has a type the spec doesn't, check the regulation — the spec may be incomplete.

Step 2: Create parameters

Follow policyengine-model-development and its parameter reference. Plus:

  • Store RATES, not derived dollar amounts, when the law defines a percentage — but only if the legal code explicitly states a percentage. If only dollar amounts appear in law, store the dollar amount.
    # ✅ When law says "185% of FPL":
    income_limit/rate.yaml:
      values: { 2024-01-01: 1.85 }
      metadata:
        reference:
          - title: OAR 461-155-0180(2)(a)
            href: https://oregon.public.law/rules/oar_461-155-0180
    
  • Only use official government sources (.gov, statutes, CFR, USC) for references. Never third-party guides, Wikipedia, or nonprofit summaries.

Step 3: Create variables

Follow policyengine-model-development and its variable and style references. Plus:

  • Entity from legal language: "per recipient / per individual / for each person" → Person; "per assistance unit / per household / for the family" → SPMUnit / TaxUnit / Household.

  • Variable reference format — bare URL string, or tuple of strings for multiple:

    reference = "https://oregon.gov/dhs/tanf-manual.pdf#page=23"
    # or:
    reference = (
        "https://oregon.public.law/rules/oar_461-155-0030",
        "https://oregon.gov/dhs/tanf-manual.pdf#page=23",
    )
    # ❌ Don't use a list:
    reference = ["https://...", "https://..."]
    
  • CRITICAL: NEVER mix parameter (YAML) and variable (Python) reference formats. Parameter references use structured dicts with title: + href:. Variable references are bare URL strings. Do not copy one format into the other.

    ❌ Forbidden — YAML structure inside a Python variable:

    reference = [
        {"title": "OAR 461-155-0030(2)(a)(B)",
         "href": "https://oregon.public.law/rules/oar_461-155-0030"},
    ]
    

    Rule of thumb: .py files use bare URL strings only. If you find yourself typing title: or href: in Python, stop — you're mixing formats.

  • TANF countable income — verify deduction order from legal code. Typical: max_() on earned income BEFORE adding unearned: return max_(gross_earned - earned_deductions, 0) + unearned. Always verify against the state's legal code — follow the law, not the pattern.

Step 4: Spec-to-implementation completeness check

Two passes after creating both parameters and variables:

Pass 1: Spec coverage — every requirement in the spec / working_references has at least one parameter AND one variable implementing it. Bulleted "other requirements" are NOT informational — they need implementation. If the spec mentions:

  • employment / work hours → create {prefix}_activity_eligible or {prefix}_work_eligible
  • citizenship / immigration → create {prefix}_immigration_eligible or use the existing federal variable
  • assets / resources → create {prefix}_resource_eligible

Pass 2: Parameter-to-variable mapping — every parameter file you created is used by at least one variable. Resource/income limit parameters MUST have corresponding _eligible variables. Main eligibility variable combines ALL checks. No orphaned parameters.

Red flag: if min_work_hours.yaml exists but no work_eligible variable uses it.

Step 5: Simplified vs full TANF

Default to Simplified unless told otherwise.

Don't create these wrapper variables (use the federal variable directly):

  • state_tanf_gross_earned_income → tanf_gross_earned_income
  • state_tanf_demographic_eligible_person → federal
  • state_tanf_assistance_unit_size → spm_unit_size
  • state_tanf_immigration_eligible → is_citizen_or_legal_immigrant

Do create these (state-specific logic):

  • state_tanf_countable_earned_income — state disregard %
  • state_tanf_income_eligible — state income limits
  • state_tanf_resource_eligible — state resource limits
  • state_tanf_maximum_benefit — state payment standards
  • state_tanf_eligible — combines ALL checks
  • state_tanf — final benefit amount

Rule: only create a state variable if it adds state-specific logic. Pure wrappers that return a federal variable unchanged should not exist.

Step 6: Validate & format

Final checklist before stopping:

  • Zero hard-coded values (except 0, 1, -1, 12)
  • All parameters have description + 4 metadata fields
  • adds for pure sums, add() for sum + logic
  • Correct period handling (period.this_year for age/assets/counts)
  • Vectorized (no if-elif-else with arrays)
  • References include subsections + #page=XX for PDFs

YAML structural integrity — verify after writing any parameter file:

  1. No values after metadata:. The metadata: block MUST be the last section. Values after it are orphaned and silently ignored — #1 cause of missing parameter data.
  2. Effective dates are under the correct state key, especially for non-standard cycles (e.g., Indiana May 1, Maryland January 1).
# ❌ WRONG — WY value orphaned after metadata
WV:
  2025-10-01: 330
metadata:
  unit: currency-USD
  2025-10-01: 510   # LOST — not under any state key
# ✅ CORRECT
WV: { 2025-10-01: 330 }
WY: { 2025-10-01: 510 }
metadata: { unit: currency-USD }

Breakdown metadata correctness — when a YAML uses breakdown: in metadata:

  1. The breakdown variable must match the actual keys (e.g., file has AK_C, NY_NYC → use snap_utility_region, NOT state_code)
  2. All data keys must exist in the breakdown enum (policyengine-core ≥ 2.20 raises ValueError otherwise)

Multi-source cross-referencing — when entering values from spreadsheets/tables:

  1. For states with non-standard effective dates, verify whether a new value supersedes or supplements existing entries
  2. For states with sub-regions (Alaska 6 SNAP regions, NY 3 sub-regions), verify each individually
  3. Spot-check ≥ 5 values against the source — pick from the beginning, middle, and end

Follow the caller's assigned scope, owned paths, source inputs, manifest path and DONE format. Write the requested implementation/fix manifest before returning. A bounded fix does not repeat the standalone implementation routine. When the caller reserves formatting for ci-fixer or pr-pusher, do not format. Otherwise format only owned changed files using the repository's formatter. Use the existing environment; do not sync or install dependencies unless execution fails because a required dependency is missing.

Do not commit or push — pr-pusher handles that.

When invoked to fix issues

  1. Read all mentioned files immediately
  2. Fix all issues with Edit / MultiEdit
  3. Create missing parameters or variables if needed
  4. Complete the entire task — no partial fixes

Sponsors

Details

Marketplace
PolicyEngine/policyengine-claude
Author
PolicyEngine
Category
Development
License
MIT
Keywords
api, flask, backend, rest, endpoints

More from PolicyEngine/policyengine-claude

All 9