diff --git a/library/productivity/human-goat/.golangci.yml b/library/productivity/human-goat/.golangci.yml
new file mode 100644
index 0000000000..c419f99381
--- /dev/null
+++ b/library/productivity/human-goat/.golangci.yml
@@ -0,0 +1,16 @@
+linters:
+ enable:
+ - errorlint
+ - govet
+ - ineffassign
+ - staticcheck
+ - unused
+ - bodyclose
+ - noctx
+ - rowserrcheck
+ - sqlclosecheck
+
+formatters:
+ enable:
+ - gofmt
+ - goimports
diff --git a/library/productivity/human-goat/.goreleaser.yaml b/library/productivity/human-goat/.goreleaser.yaml
new file mode 100644
index 0000000000..7d5158a7c2
--- /dev/null
+++ b/library/productivity/human-goat/.goreleaser.yaml
@@ -0,0 +1,51 @@
+version: 2
+project_name: human-goat-pp-cli
+changelog:
+ disable: true
+builds:
+ - id: human-goat-pp-cli
+ main: ./cmd/human-goat-pp-cli
+ binary: human-goat-pp-cli
+ env:
+ - CGO_ENABLED=0
+ ldflags:
+ - -s -w -X github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cli.version={{ .Version }}
+ targets:
+ - darwin_amd64
+ - darwin_arm64
+ - linux_amd64
+ - linux_arm64
+ - windows_amd64
+ - windows_arm64
+ - id: human-goat-pp-mcp
+ main: ./cmd/human-goat-pp-mcp
+ binary: human-goat-pp-mcp
+ env:
+ - CGO_ENABLED=0
+ ldflags:
+ - -s -w -X main.version={{ .Version }}
+ targets:
+ - darwin_amd64
+ - darwin_arm64
+ - linux_amd64
+ - linux_arm64
+ - windows_amd64
+ - windows_arm64
+archives:
+ - formats: [tar.gz]
+ name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
+ format_overrides:
+ - goos: windows
+ formats: [zip]
+checksum:
+ name_template: checksums.txt
+brews:
+ - name: human-goat-pp-cli
+ repository:
+ owner: mvanhorn
+ name: homebrew-tap
+ homepage: "https://github.com/mvanhorn/printing-press-library"
+ description: "Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary."
+ install: |
+ bin.install "human-goat-pp-cli"
+ bin.install "human-goat-pp-mcp"
diff --git a/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/discovery/u4-funnel-shapes.md b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/discovery/u4-funnel-shapes.md
new file mode 100644
index 0000000000..f3d19b2941
--- /dev/null
+++ b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/discovery/u4-funnel-shapes.md
@@ -0,0 +1,110 @@
+# U4 Funnel Capture — TaskRabbit `page.book.*` shapes (read-only)
+
+Captured live from the authenticated Chrome session on 2026-07-03. **No booking placed.**
+Walked the Help Moving funnel (template_id `2247`, category_id `6`) Describe → Browse →
+Schedule → Confirm, capturing real request/response shapes via a page-context fetch
+interceptor + tRPC dehydrated cache + Zod-rejection probing. All PII values redacted;
+only field names/types recorded.
+
+## Reachability gate (Phase 1.9): PASS
+- REST `/api/v3/*` authenticated GETs return 200 with cookies alone (session + XSRF-TOKEN).
+- Account metro: **1053 (SF Bay Area, US)**. `payment_method_types: [card, amazon_pay]`.
+- **Card on file present** (masked, ends 1007) — autonomous checkout (R6) is feasible.
+- Mode: `standard_http` + cookie auth. No WAF/bot-vendor. Mutations need `X-CSRF-Token` (from ``) + cookies.
+
+## tRPC transport
+- Base: `/next-api/trpc/?batch=1`
+- Query (GET): `&input=}}>`
+- Mutation (POST): body `{"0":{"json":}}`, header `X-CSRF-Token`
+- Success envelope: `[{result:{data:{json:}}}]`
+- Error envelope: `[{error:{json:{message:, data:{code, httpStatus, zodError}}}}]`
+- Empty/partial input → 400 with a stringified Zod issue list (used to pin field names read-only).
+
+## page.book.details (query) — funnel step 1
+- **Input:** `{ jobDraftGuid: object|string, locale: string, taskTemplateId: string }`
+- **Output:** `bff{ fieldGroups[]{key,title,fields[]}, jobDraft, routingQuestion{questionText,helpText,options[]}, templateTitle, title }, meta{ category{id,name}, hideVehicles, marketingGroup{id,name}, scopingQuestionsAndOptions... }`
+- Help Moving scoping question observed: "Do you need your Tasker to provide a vehicle?" (Yes/No), then Start/End address + task size (Small/Medium/Large) + free-text description.
+
+## page.book.recommendations (query) — funnel step 2 (KILLER endpoint)
+- **Input (top-level):** `{ location: object, schedule: { dates: [], dayTimeRanges: [] }, locale, taskTemplateId, jobDraftGuid }`
+ - `location` internal shape includes geocoded `{lat, lng, ...address parts}` (from the Describe step's start/end addresses).
+- **Output:** `bff{ histogram{...}, recommendations[] }`
+ - `histogram`: `currency_code`, `bars[]{attributes{number_of_taskers, minimum_price_cents, formatted_minimum_price}}` (28 bars), `minimum_price_cents`, `median_price_cents`, `maximum_price_cents`, `step_cents`, `expensive_threshold_cents` (+ currency/symbol/formatted variants).
+ - `recommendations[]` (56 taskers) item fields (ranking + pricing inputs for U3/U6):
+ - Identity: `id`, `user_id`, `slug`, `first_name`, `display_name`, `avatar_url*`, `metro_name`, `locale`, `category_id`, `category_name`
+ - Ratings/experience: `rabbit_rating`, `rabbit_average_review`, `rabbit_number_of_reviews`, `rabbit_number_of_message_reviews`, `category_review_count`, `category_family_average_star_rating`, `category_family_review_count`, `category_invoices_count` (tasks completed in category), `category_family_invoices_count`, `job_approved_invoice_count`, `hours_worked`, `experience_level`, `elite`, `reliability_rate`, `response_time`, `most_recent_review`, `reviews_categories`, `identity_label`
+ - Flags: `is_favorite`, `past_tasker`, `disabled`, `show_value_badge`, `show_ikea_assembly_badge`, `two_hour_minimum_required_display`, `vehicles`, `vehicles_display`, `special_tools_display`, `spoken_languages_display`
+ - Availability: `next_available_at`, `schedule` (per-tasker)
+ - **Pricing (the all-in inputs):**
+ - `poster_hourly_rate_cents` / `_currency` / `_symbol` / `formatted_poster_hourly_rate` ← **client-paid base hourly rate** (use this for all-in calc)
+ - `poster_fixed_rate_cents` / `formatted_poster_fixed_rate` / `poster_rate_display`
+ - `rabbit_hourly_rate_cents` / `formatted_rabbit_hourly_rate` (what the Tasker earns)
+ - `discount_saving_hourly_rate_cents` / `discounted_prices`
+- **Observed all-in reality:** Browse shows base `poster_hourly_rate` (e.g. Razhap A. $33.33/hr); Confirm charges the fee-folded effective rate ($44.66/hr ≈ +34%). Confirms the all-in transcendence feature: fold service + trust & support fees; surface the confirm-level rate everywhere.
+
+## page.book.schedule (query) — funnel step 3
+- **Input:** `{ categoryId: number, inviteeId: number (selected tasker id), locale: string, location: { lat: number, lng: number }, taskTemplateId: number }`
+- **Output:** `bff{ availableDates[]{ date: string, sameday: boolean, slots[]{ durationSeconds: number, offsetSeconds: number, selectLabel: string } }, surgePrice, tasker{ avatarUrl, displayName } }`
+- A slot is `(date, offsetSeconds, durationSeconds)`.
+
+## page.book.confirm (query) — funnel step 4
+- Client-fetched (no SSR cache); rendered summary read from DOM.
+- Shows: selected Tasker, date/time, 2-hour minimum, start/end addresses, task size, task description, **payment method (card on file, masked)**, promo-code field, "Donate $1" toggle.
+- **Cancellation policy text (resolves OQ2 window semantics):** *"we charge a 1 hour deposit that is fully refundable if cancelled at least 24 hours before your appointment. If your task takes more than 1 hour, you'll be charged the remaining balance at $/hr once it's done."*
+ - **Free cancellation window = ≥24h before the appointment.** Deposit = 1 hour (refundable inside the window).
+- Commit button label: **"Confirm and chat"** (this is the checkout/hire commit — NOT clicked).
+
+## Mutations
+- **Commit / hire (the checkout):** NOT `page.book.hire` (that path returns `No "mutation"-procedure`). None of `page.book.{create,book,submit,checkout,reserve,request,hireTasker}`, `page.booking.create`, `page.job.create` exist as mutations (all 404 `No mutation-procedure`). The real commit mutation fires **only on the "Confirm and chat" click** and could not be captured read-only.
+ - **DEFERRED to the plan's authorized single real `hire`+`cancel` round-trip (U6 acceptance).** That round-trip is where the commit request+response is captured — safely, because `cancelTask` (below) is wired and verified first, and it happens with explicit user go-ahead and the spend cap in place. Do NOT guess the commit mutation name; capture it during that round-trip.
+- **Cancel (R7):** **`page.tasks.cancelTask`** (mutation, confirmed — returns 400 Zod on empty input).
+ - Input is a **discriminated union on `type`**: `type: 'single' | ...` (e.g. single vs recurring/all). Full member fields (taskId/appointmentId, reason?) to be filled when wiring — the discriminator + procedure name are pinned.
+
+## page.tasks.list (query) — bookings list (U2 store)
+- **Input:** `{ page: number, perPage: number, filters: object, locale: string }` (`filters` is a required object; `locale` e.g. `en-US`. A bare `{}` filters value tripped an `invalid_value` enum on a filter field — filter sub-keys to be pinned when wiring the sync.)
+- **Output (from prior sniff, unchanged):** `bff{ items[]{ details{advanceOrder,promotionCode,...}, taskers[]{review,status,paymentFailed,...}, status, futureAppointments, notification }, page, totalItems, totalPages }`
+
+## Net for U6
+Buildable now without guessing: the funnel read chain (details → recommendations → schedule → confirm),
+the all-in price inputs (`poster_hourly_rate_cents` + confirm effective rate), ranking inputs, the
+schedule slot shape, the cancel mutation (`page.tasks.cancelTask`, discriminated union), the 24h free
+cancellation window, and the bookings list. The ONE deferred item is the exact commit mutation
+name+shape, captured during the authorized real `hire`+`cancel` acceptance round-trip.
+
+## COMMIT mutation — CAPTURED (2026-07-03, real booking, immediately cancelled)
+
+**`POST /api/v3/jobs/post/hire.json`** (REST, not tRPC) — the "Confirm and chat" checkout.
+Needs `X-CSRF-Token` + cookies. Body:
+
+```
+{
+ "source": "recommendation",
+ "job_type": "Template",
+ "fixed_rate": false,
+ "seconds_between": "0",
+ "shown_cancellation_policy": true,
+ "task_template_id": 2247,
+ "category_id": 6,
+ "category_name": "Help Moving",
+ "title": "Help Moving",
+ "marketing_group_id": 15, // from details bff meta.marketingGroup.id
+ "funnel_id": "", // not server-validated (same as recommendations)
+ "session_id": "<52-char funnel token>", // client-generated; not a cookie; validation untested
+ "recommendation_id": "",
+ "invitee_id": , // == rabbit_id
+ "rabbit_id": ,
+ "poster_hourly_rate_cents": ,
+ "job_draft_guid": "",
+ "form_referrer": "",
+ "job_size": "small|medium|large",
+ "description": "",
+ "schedule": { "date": "YYYY-MM-DD", "duration_seconds": , "offset_seconds": },
+ "address": { address1, address2, country, formatted_address, lat, lng, locality, metro_id, metro_name, postal_code, region },
+ "secondary_location":{ same shape as address — the END address for moving }
+}
+```
+
+- `recommendation_id`: top-level `bff.recommendation_id` on the recommendations response (e.g. `Organic::MultiDayRecommendationsOp-...`).
+- Tasker numeric id = recommendation item `user_id` (id is `profile_`).
+- **Open dependency for fully-autonomous CLI hire:** the rich `address`/`secondary_location` need geocoding incl. TaskRabbit's internal `metro_id` (Seattle addr = 1057, differs from account metro 1053). recommendations tolerates lat/lng-only; whether the commit does is untested (would require a real booking to confirm).
+- Verified cancel: `page.tasks.cancelTask {type:"single", jobId, rabbitId, reason}` (wired into `goat cancel`).
diff --git a/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/proofs/phase5-acceptance.json b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/proofs/phase5-acceptance.json
new file mode 100644
index 0000000000..603af54cc6
--- /dev/null
+++ b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/proofs/phase5-acceptance.json
@@ -0,0 +1,13 @@
+{
+ "schema_version": 1,
+ "api_name": "human-goat",
+ "run_id": "20260703-152050-dff84056",
+ "status": "pass",
+ "level": "quick",
+ "matrix_size": 12,
+ "tests_passed": 12,
+ "tests_skipped": 13,
+ "auth_context": {
+ "type": "cookie"
+ }
+}
\ No newline at end of file
diff --git a/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research.json b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research.json
new file mode 100644
index 0000000000..f979126049
--- /dev/null
+++ b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research.json
@@ -0,0 +1,238 @@
+{
+ "api_name": "human-goat",
+ "novelty_score": 0,
+ "alternatives": [],
+ "gaps": [],
+ "patterns": [],
+ "recommendation": "proceed",
+ "researched_at": "2026-07-03T00:00:00Z",
+ "novel_features": [
+ {
+ "name": "Autonomous soup-to-nuts hire",
+ "command": "hire",
+ "description": "Say the job and the date; goat searches, ranks by review quality and honest all-in price, and checks out against the card on file with no prompt.",
+ "rationale": "Only a client-side agent can walk the whole TaskRabbit funnel and commit checkout unattended; the app forces a human through every step.",
+ "example": "human-goat-pp-cli hire \"help moving\" --on 2026-07-11 --min-rating 4.8 --max-total 200 --agent --lat 47.6062 --lng -122.3321",
+ "why_it_matters": "Reach for this when the user wants a task done, not a Tasker shortlist to review.",
+ "group": "Hands-off hiring"
+ },
+ {
+ "name": "Verified cancel (the safety gate)",
+ "command": "cancel",
+ "description": "Cancels a booking and confirms it landed by re-reading status, reporting whether it was inside the free window and any fee.",
+ "rationale": "Requires reading back booking state after the mutation; a fire-and-forget cancel would defeat the whole hands-off safety model.",
+ "example": "human-goat-pp-cli cancel task_abc123 --agent",
+ "why_it_matters": "This is the undo that makes autonomous checkout tolerable; always available after a hire.",
+ "group": "Hands-off hiring"
+ },
+ {
+ "name": "Spend cap",
+ "command": "hire",
+ "description": "Refuses to check out when the computed all-in total exceeds a configurable ceiling, printing the total and the cap.",
+ "rationale": "Bounds blast radius on unattended checkout without adding a per-hire human prompt.",
+ "example": "human-goat-pp-cli hire movers --on saturday --min-rating 4.9 --max-total 150 --lat 47.6062 --lng -122.3321",
+ "why_it_matters": "Use --max-total (or the config default) to cap autonomous spend before any booking is placed.",
+ "group": "Hands-off hiring"
+ },
+ {
+ "name": "All-in price",
+ "command": "best",
+ "description": "Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate, honoring the CA/MA service-fee-only rule.",
+ "rationale": "The app hides fees until checkout; the CLI computes the honest effective rate client-side and makes it the default price everywhere.",
+ "example": "human-goat-pp-cli best \"help moving\" --on saturday --min-rating 4.9 --agent",
+ "why_it_matters": "Every price surface (search, compare, best, hire, spend) shows the real all-in rate, not the teaser.",
+ "group": "Honest pricing"
+ },
+ {
+ "name": "Cross-source dispatch",
+ "command": "dispatch",
+ "description": "Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override.",
+ "rationale": "Only a unified binary over both human networks can pick the right one from the task alone.",
+ "example": "human-goat-pp-cli dispatch \"call the dentist and reschedule my cleaning\"",
+ "why_it_matters": "Say what you want done and let goat pick the human network; force it with --via taskrabbit|magic.",
+ "group": "One surface, two human networks"
+ },
+ {
+ "name": "Cross-source spend analytics",
+ "command": "spend",
+ "description": "SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month, using true effective all-in $/hr for TaskRabbit.",
+ "rationale": "Requires a local store joining bookings, invoices, and remote tasks that only exist together offline.",
+ "example": "human-goat-pp-cli spend --by source --agent",
+ "why_it_matters": "Answers where the money went across both human networks with fees folded in.",
+ "group": "One surface, two human networks"
+ },
+ {
+ "name": "Availability watch",
+ "command": "watch",
+ "description": "Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens.",
+ "rationale": "The app has no watch primitive; polling the recommendations endpoint from the store enables it.",
+ "example": "human-goat-pp-cli watch movers --on saturday --max-rate 60",
+ "why_it_matters": "Use when nothing good is available now and you want the first qualifying slot.",
+ "group": "Honest pricing"
+ },
+ {
+ "name": "Unified in-flight inbox",
+ "command": "status",
+ "description": "One list of every in-flight task across TaskRabbit bookings and Magic requests, joined on the common task model and sorted by state.",
+ "rationale": "Only a unified store joining two human networks can show all pending work in one place; each backend only knows its own.",
+ "example": "human-goat-pp-cli status --open --agent",
+ "why_it_matters": "Use for a cross-source view of everything in flight; use 'tasks list' for TR-only and 'track \u003cid\u003e' for one Magic request.",
+ "group": "One surface, two human networks"
+ }
+ ],
+ "novel_features_built": [
+ {
+ "name": "Autonomous soup-to-nuts hire",
+ "command": "hire",
+ "description": "Say the job and the date; goat searches, ranks by review quality and honest all-in price, and checks out against the card on file with no prompt.",
+ "rationale": "Only a client-side agent can walk the whole TaskRabbit funnel and commit checkout unattended; the app forces a human through every step.",
+ "example": "human-goat-pp-cli hire \"help moving\" --on 2026-07-11 --min-rating 4.8 --max-total 200 --agent --lat 47.6062 --lng -122.3321",
+ "why_it_matters": "Reach for this when the user wants a task done, not a Tasker shortlist to review.",
+ "group": "Hands-off hiring"
+ },
+ {
+ "name": "Verified cancel (the safety gate)",
+ "command": "cancel",
+ "description": "Cancels a booking and confirms it landed by re-reading status, reporting whether it was inside the free window and any fee.",
+ "rationale": "Requires reading back booking state after the mutation; a fire-and-forget cancel would defeat the whole hands-off safety model.",
+ "example": "human-goat-pp-cli cancel task_abc123 --agent",
+ "why_it_matters": "This is the undo that makes autonomous checkout tolerable; always available after a hire.",
+ "group": "Hands-off hiring"
+ },
+ {
+ "name": "Spend cap",
+ "command": "hire",
+ "description": "Refuses to check out when the computed all-in total exceeds a configurable ceiling, printing the total and the cap.",
+ "rationale": "Bounds blast radius on unattended checkout without adding a per-hire human prompt.",
+ "example": "human-goat-pp-cli hire movers --on saturday --min-rating 4.9 --max-total 150 --lat 47.6062 --lng -122.3321",
+ "why_it_matters": "Use --max-total (or the config default) to cap autonomous spend before any booking is placed.",
+ "group": "Hands-off hiring"
+ },
+ {
+ "name": "All-in price",
+ "command": "best",
+ "description": "Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate, honoring the CA/MA service-fee-only rule.",
+ "rationale": "The app hides fees until checkout; the CLI computes the honest effective rate client-side and makes it the default price everywhere.",
+ "example": "human-goat-pp-cli best \"help moving\" --on saturday --min-rating 4.9 --agent",
+ "why_it_matters": "Every price surface (search, compare, best, hire, spend) shows the real all-in rate, not the teaser.",
+ "group": "Honest pricing"
+ },
+ {
+ "name": "Cross-source dispatch",
+ "command": "dispatch",
+ "description": "Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override.",
+ "rationale": "Only a unified binary over both human networks can pick the right one from the task alone.",
+ "example": "human-goat-pp-cli dispatch \"call the dentist and reschedule my cleaning\"",
+ "why_it_matters": "Say what you want done and let goat pick the human network; force it with --via taskrabbit|magic.",
+ "group": "One surface, two human networks"
+ },
+ {
+ "name": "Cross-source spend analytics",
+ "command": "spend",
+ "description": "SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month, using true effective all-in $/hr for TaskRabbit.",
+ "rationale": "Requires a local store joining bookings, invoices, and remote tasks that only exist together offline.",
+ "example": "human-goat-pp-cli spend --by source --agent",
+ "why_it_matters": "Answers where the money went across both human networks with fees folded in.",
+ "group": "One surface, two human networks"
+ },
+ {
+ "name": "Availability watch",
+ "command": "watch",
+ "description": "Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens.",
+ "rationale": "The app has no watch primitive; polling the recommendations endpoint from the store enables it.",
+ "example": "human-goat-pp-cli watch movers --on saturday --max-rate 60",
+ "why_it_matters": "Use when nothing good is available now and you want the first qualifying slot.",
+ "group": "Honest pricing"
+ },
+ {
+ "name": "Unified in-flight inbox",
+ "command": "status",
+ "description": "One list of every in-flight task across TaskRabbit bookings and Magic requests, joined on the common task model and sorted by state.",
+ "rationale": "Only a unified store joining two human networks can show all pending work in one place; each backend only knows its own.",
+ "example": "human-goat-pp-cli status --open --agent",
+ "why_it_matters": "Use for a cross-source view of everything in flight; use 'tasks list' for TR-only and 'track \u003cid\u003e' for one Magic request.",
+ "group": "One surface, two human networks"
+ }
+ ],
+ "auth": {},
+ "narrative": {
+ "display_name": "Human-Goat",
+ "headline": "Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary.",
+ "value_prop": "goat unifies two human networks behind a common task model: TaskRabbit for in-person local labor and Magic for remote errands. Its headline is hands-off checkout on TaskRabbit against the card on file — searched, ranked by honest all-in price and review quality, and booked with no prompt — made safe by a spend cap and a cancel command that verifies the cancellation actually landed.",
+ "auth_narrative": "TaskRabbit auth is cookie replay from a logged-in Chrome session: run `human-goat-pp-cli auth login --chrome` to lift the session + XSRF-TOKEN cookies. Never a programmatic password login (the login form is reCAPTCHA-gated). Magic uses an x-api-key read from $MAGIC_API_KEY or ~/.magic/api_key.",
+ "quickstart": [
+ {
+ "command": "human-goat-pp-cli doctor --dry-run",
+ "comment": "check both backends and resolve the account metro before doing anything"
+ },
+ {
+ "command": "human-goat-pp-cli categories list --agent",
+ "comment": "see the task templates available in your metro"
+ },
+ {
+ "command": "human-goat-pp-cli best \"help moving\" --on saturday --min-rating 4.9 --agent",
+ "comment": "find the best-value available Tasker with all-in pricing"
+ },
+ {
+ "command": "human-goat-pp-cli hire \"help moving\" --on saturday --min-rating 4.9 --max-total 200 --dry-run --lat 47.6062 --lng -122.3321",
+ "comment": "render the confirm summary without booking; drop --dry-run to check out for real"
+ }
+ ],
+ "troubleshoots": [
+ {
+ "symptom": "doctor reports TaskRabbit unauthenticated",
+ "fix": "run `human-goat-pp-cli auth login --chrome` while logged into taskrabbit.com in Chrome"
+ },
+ {
+ "symptom": "hire refuses with a spend-cap message",
+ "fix": "raise --max-total or pick a cheaper Tasker; the printed all-in total exceeded the cap"
+ },
+ {
+ "symptom": "Magic task shows ONGOING forever",
+ "fix": "the human's answer arrives in the conversation, not result; read it with `track \u003cid\u003e` which surfaces the conversation tail"
+ }
+ ],
+ "when_to_use": "Use goat when an agent should get a real-world task done end to end — hire a mover, assemble furniture, make a phone call, book something online — rather than surfacing options for a human to finish. It is strongest for autonomous TaskRabbit hiring with a safety net and for dispatching remote errands to Magic.",
+ "anti_triggers": [
+ "Do not use goat to edit the payment method on file — the card is managed in the TaskRabbit app.",
+ "Do not use goat for programmatic password login to either service — auth is cookie/api-key only.",
+ "Do not use goat to move money outside a task booking — no transfers, payouts, or standalone tipping."
+ ],
+ "recipes": [
+ {
+ "title": "Autonomous hire with a spend cap",
+ "command": "human-goat-pp-cli hire \"help moving\" --on 2026-07-11 --min-rating 4.8 --max-total 200 --agent --lat 47.6062 --lng -122.3321",
+ "explanation": "Searches, ranks by all-in price x reviews, checks out under the cap, and prints the booking id, Tasker, time, and total with no prompt."
+ },
+ {
+ "title": "Undo a booking and verify",
+ "command": "human-goat-pp-cli cancel task_abc123 --agent",
+ "explanation": "Cancels, re-reads status, and reports cancelled plus whether it was inside the free window and any fee."
+ },
+ {
+ "title": "Dispatch a remote errand",
+ "command": "human-goat-pp-cli call 5209076052 \"when does the jewelry store open\"",
+ "explanation": "Sends a phone-call task to Magic and returns a request id to track; the answer comes back in the conversation."
+ },
+ {
+ "title": "Narrow a verbose payload for an agent",
+ "command": "human-goat-pp-cli taskers favorites --agent --select items.name,items.all_in_rate,items.rating",
+ "explanation": "Returns only the fields an agent needs from a deeply nested Tasker list instead of the full payload."
+ },
+ {
+ "title": "Where did the money go",
+ "command": "human-goat-pp-cli spend --by source --agent",
+ "explanation": "Splits TaskRabbit vs Magic totals from the local store, with TaskRabbit rows using true all-in effective rate."
+ }
+ ],
+ "trigger_phrases": [
+ "hire a mover on saturday",
+ "book someone to assemble my furniture",
+ "call this number and ask",
+ "who did i hire before",
+ "cancel that booking",
+ "use human-goat",
+ "run goat"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-feat-human-goat-pp-cli-absorb-manifest.md b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-feat-human-goat-pp-cli-absorb-manifest.md
new file mode 100644
index 0000000000..0190b2de6b
--- /dev/null
+++ b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-feat-human-goat-pp-cli-absorb-manifest.md
@@ -0,0 +1,52 @@
+# Human-Goat CLI Absorb Manifest
+
+Combo CLI over two human-labor networks. Absorb target = the TaskRabbit web app surface
+(sniffed live) + the working Magic bash CLI. Approved at Phase Gate 1.5 (2026-07-03).
+
+## Absorbed (match the app + Magic)
+| # | Feature | Source | Our command | Disposition |
+|---|---------|--------|-------------|-------------|
+| 1 | Browse categories/templates | TR metro_task_template | human-goat-pp-cli categories | (generated endpoint) |
+| 2 | Resolve account metro | TR bootstrap | (behavior in human-goat-pp-cli doctor) | |
+| 3 | List bookings | TR page.tasks.list | (behavior in human-goat-pp-cli tasks list) | hand-code (tRPC adapter) |
+| 4 | Booking detail | TR page.tasks.list item | (behavior in human-goat-pp-cli tasks get) | hand-code |
+| 5 | Favorite Taskers | TR poster_rabbit_relationships/favorite | human-goat-pp-cli taskers favorites | (generated endpoint) |
+| 6 | Past Taskers | TR poster_rabbit_relationships/past | human-goat-pp-cli taskers past | (generated endpoint) |
+| 7 | Tasker suggestions | TR dashboard/tasker_suggestions | human-goat-pp-cli taskers suggestions | (generated endpoint) |
+| 8 | Account profile | TR account.json | human-goat-pp-cli account | (generated endpoint) |
+| 9 | Search Taskers + prices | TR page.book.recommendations | human-goat-pp-cli search | hand-code (tRPC adapter) |
+| 10 | Availability slots | TR page.book.schedule | (behavior in human-goat-pp-cli availability) | hand-code |
+| 11 | Confirm/all-in quote | TR page.book.confirm | (behavior in human-goat-pp-cli book quote) | hand-code |
+| 12 | Hire/commit | TR commit mutation | human-goat-pp-cli hire | hand-code, GATED (real round-trip) |
+| 13 | Cancel | TR page.tasks.cancelTask | human-goat-pp-cli cancel | hand-code, GATED |
+| 14 | Reschedule | TR mutation | (behavior in human-goat-pp-cli reschedule) | hand-code, GATED |
+| 15 | Rescue/re-match | TR page.tasks.rescueRecommendation | (behavior in human-goat-pp-cli rescue) | hand-code |
+| 16 | Invoices/history | TR invoices | human-goat-pp-cli invoices | (generated endpoint) |
+| 17 | Cookie auth from Chrome | TR session | human-goat-pp-cli auth login --chrome | (generated) |
+| 18 | Create remote request | Magic POST /request | (behavior in human-goat-pp-cli send) | hand-code (Magic adapter) |
+| 19 | Phone-call errand | Magic POST /request | (behavior in human-goat-pp-cli call) | hand-code |
+| 20 | Request status/answer | Magic GET /request/{id} | (behavior in human-goat-pp-cli track) | hand-code |
+| 21 | Reply into conversation | Magic POST /conversation | (behavior in human-goat-pp-cli reply) | hand-code |
+
+## Transcendence (approved at gate, all hand-code)
+| # | Feature | Command | Buildability | Score | Why only we can do it |
+|---|---------|---------|--------------|-------|------------------------|
+| 1 | Honest all-in ranking | best | hand-code | 10/10 | Fold the empirical +34% fee markup into a ranked shortlist the app never shows |
+| 2 | Cross-source spend analytics | spend | hand-code | 9/10 | Local SQL over bookings+invoices+magic tasks; true all-in effective $/hr |
+| 3 | Cross-source dispatch | dispatch | hand-code | 9/10 | Task-shape routing across two human networks; --via override |
+| 4 | Unified in-flight inbox | status | hand-code | 7/10 | Local join of TR bookings + Magic requests on the common Task model |
+| 5 | Qualifying-opening watch | watch | hand-code | 7/10 | Poll availability with a rating/all-in-price predicate; agent-shaped single match |
+
+Dropped at gate: compare (folded into best), rebook (folded into hire+watch).
+
+## Build division (why some is hand-code)
+- **Generator produced:** the `goat` scaffold, cookie auth (`auth login --chrome`), config, SQLite
+ store, `doctor`, framework (sync/search), MCP tree, README/SKILL, and the TaskRabbit REST
+ `/api/v3` read commands (categories, taskers, account, invoices). Verified: builds, vets,
+ govulncheck, doctor all pass.
+- **Hand-code (the combo pattern + transcendence):** the TaskRabbit tRPC funnel and Magic second
+ source cannot live in one generator spec (two base URLs, two auth models, non-REST tRPC envelope),
+ so they are `internal/source/{taskrabbit,magic}/` adapters — exactly how every combo CLI in the
+ library (flightgoat, contact-goat, prediction-goat) is built. The 5 transcendence commands are
+ hand-code by definition (the generator never emits transcendence). All-in math lives in
+ `internal/pricing`.
diff --git a/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-feat-human-goat-pp-cli-brief.md b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-feat-human-goat-pp-cli-brief.md
new file mode 100644
index 0000000000..8790839af6
--- /dev/null
+++ b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-feat-human-goat-pp-cli-brief.md
@@ -0,0 +1,81 @@
+# Human-Goat CLI Brief
+
+One binary, `goat`, that dispatches real-world tasks to real humans across two backends and
+tracks them to completion: **TaskRabbit** for in-person local labor and **Magic** for remote
+errands. Headline capability: autonomous soup-to-nuts hire on TaskRabbit against the card on
+file, made safe by a verified cancel + spend cap.
+
+## API Identity
+- Two human-labor networks behind one common task model.
+- **TaskRabbit** (primary): IKEA-owned local-services marketplace. Cookie auth (session + XSRF-TOKEN
+ from logged-in Chrome; reCAPTCHA on login so never headless). REST `/api/v3/*` reads + tRPC
+ `/next-api/trpc/*` BFF funnel. Metro-scoped (account metro 1053, SF Bay Area). Card on file present.
+- **Magic** (secondary): remote human-assistant API (getmagic.com). `x-api-key` REST:
+ POST /request, GET /request/{id}, POST /conversation. Status flows PENDING->ONGOING->COMPLETED;
+ answer arrives in the conversation array, not `result`.
+
+## Users (concrete)
+- **Matt, the operator (or an agent acting as Matt):** wants to say "go be a mover on this date with
+ good reviews" and have it done end to end — the agent manages the human. Runs `hire`, `track`,
+ `cancel`, `dispatch` from the terminal or Claude Code. Today he taps through the TaskRabbit app one
+ hidden-fee Tasker at a time, or click-drives the Magic console with no terminal surface.
+- **The comparison shopper:** before booking a mover/mounter/cleaner, wants the honest all-in price
+ and review quality of every available Tasker across a date window, side by side. Today the app
+ shows one Tasker at a time and hides fees until checkout.
+- **The delegator running errands:** needs a phone call made, a business's hours confirmed, an online
+ booking placed, or data entered — remote work a human assistant can do. Today he click-drives the
+ Magic console and polls for the answer.
+- **The repeat hirer:** found a Tasker who worked out; wants to re-book them for a new date without
+ re-searching, and to watch for a near-term opening.
+
+## Top Workflows (named rituals)
+1. **Autonomous hire (F1):** `goat hire movers --on saturday --min-rating 4.9 --max-total 200` ->
+ search -> rank by all-in price x review quality -> spend-cap check -> checkout on card on file ->
+ print booking id + total. No prompt.
+2. **Undo (F2):** `goat cancel ` -> cancel mutation -> re-read status -> confirm cancelled
+ + report free-window/fee. The safety valve that makes hands-off checkout tolerable.
+3. **Comparison shop:** `goat best "help moving" --on sat --min-rating 4.9` / `goat compare` -> honest
+ all-in ranked Taskers the app hides.
+4. **Remote errand (F3):** `goat call 5209076052 "when does the jewelry store open"` -> Magic request
+ -> `goat track ` until terminal -> answer read from conversation.
+5. **Cross-source dispatch:** `goat dispatch ""` routes to Magic (remote-doable) or TaskRabbit
+ (in-person) by task shape, with `--via` override.
+6. **Spend analytics:** `goat spend --by source|category|tasker|month` -> SQL over local
+ booking/invoice/Magic-task history with true all-in effective $/hr.
+7. **Rebook / watch:** re-hire a favorite; poll for a near-term qualifying opening.
+
+## Reachability Risk
+- LOW. TaskRabbit: standard_http + cookie; REST reads 200 with cookies alone; no WAF; reCAPTCHA only
+ on login (handled by cookie import). Magic: plain REST + x-api-key (working key on file).
+
+## Data Layer
+- Common `Task {id, source, status, title, created_at, ...}`. Entities: tasker, category, metro,
+ booking, appointment, invoice, magic_request. FTS over taskers/categories; SQL over
+ bookings+invoices+magic tasks for `track`/`spend` and ranking.
+
+## Killer differentiators (only the CLI can do)
+- **Autonomous checkout** on the card on file with no pre-charge prompt — gated by post-hoc verified
+ cancel + spend cap (empirically: free cancel >=24h before appointment; 1hr refundable deposit).
+- **Honest all-in price** everywhere: empirically $33.33 base -> $44.66 charged (~+34%), CA/MA
+ service-fee-only. The app hides this.
+- **One surface over two human networks** with task-shape routing.
+- Verified cancel (re-reads status), availability watch, first-class rebook, cross-source spend.
+
+## U4 empirical findings (live capture)
+- Funnel: details -> recommendations (56 taskers, `poster_hourly_rate_cents` + rating/tasks/elite/
+ availability) -> schedule (slots) -> confirm (all-in total, card on file). Cancel mutation =
+ `page.tasks.cancelTask` (discriminated union on `type: single`). Commit mutation captured during
+ the authorized real hire+cancel round-trip.
+
+## User Vision (from plan)
+Soup-to-nuts autonomous hire: "go be a mover on this date with good reviews" — the agent searches,
+filters by reviews, books end to end against the card on file, and manages the human. Autonomous
+checkout is gated NOT by a pre-charge confirm but by a reliable post-hoc verified cancel + a spend
+cap. Cross-source verbs (dispatch/track/spend) let the user ignore which backend does the work.
+Deferred: multi-Tasker crew, recurring planner, sending TR messages, editing payment method.
+
+## Product Thesis
+- Name: `goat` (binary human-goat-pp-cli; display Human-Goat).
+- Why it exists: say what you want done to a human and have it done — hire in person via TaskRabbit
+ with honest pricing and autonomous, undoable checkout, or dispatch remote errands to Magic — from
+ one agent-native binary.
diff --git a/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-novel-features-brainstorm.md b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-novel-features-brainstorm.md
new file mode 100644
index 0000000000..bb0a970a50
--- /dev/null
+++ b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/2026-07-03-152050-novel-features-brainstorm.md
@@ -0,0 +1,11 @@
+# human-goat novel-features brainstorm (Phase 1.5c.5 subagent audit trail)
+
+Survivors (>=5/10, all hand-code): best, spend, dispatch, status, watch.
+Killed: rebook (subsumed by hire+watch), compare (subsumed by best), effective-rate
+(facet of spend), cancel-window (subset of verified cancel), fees (restates book quote),
+ledger (overlaps tasks list + spend).
+
+NOTE (divergence from plan): the subagent cut `compare` (plan R5) and `rebook` (plan R10).
+Surfaced at the gate for the user to override.
+
+Full customer model + candidates + kills preserved in the run record (subagent output).
diff --git a/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/human-goat-spec.yaml b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/human-goat-spec.yaml
new file mode 100644
index 0000000000..ca0e842fe5
--- /dev/null
+++ b/library/productivity/human-goat/.manuscripts/20260703-152050-dff84056/research/human-goat-spec.yaml
@@ -0,0 +1,243 @@
+name: human-goat
+display_name: Human-Goat
+description: |-
+ goat dispatches real-world tasks to real humans and tracks them to completion
+ across two backends: TaskRabbit for in-person local labor (moving, assembly,
+ mounting, cleaning) and Magic for remote errands (phone calls, lookups, online
+ bookings, data entry). The headline capability is autonomous soup-to-nuts hire
+ on TaskRabbit against the card on file, made safe by a verified cancel and a
+ spend cap. TaskRabbit is authenticated via Chrome cookies (session + XSRF-TOKEN,
+ never headless login); Magic via x-api-key.
+version: "0.1.0"
+cli_description: "Hire real humans from the terminal — autonomous TaskRabbit checkout with all-in pricing and a verified undo, plus Magic remote errands, in one agent-native binary."
+category: productivity
+base_url: https://www.taskrabbit.com
+health_check_path: /api/v3/web-client/bootstrap.json
+
+# TaskRabbit is the PRIMARY source (cookie auth). Its REST /api/v3 read surface is
+# modeled below. Everything else is hand-authored (Codex) because it cannot live in
+# one spec:
+# - TaskRabbit tRPC BFF (/next-api/trpc/*): page.tasks.list read + the page.book.*
+# funnel (details/recommendations/schedule/confirm/hire). Envelope
+# [{result:{data:{json:...}}}]; Zod input shapes pinned by the U4 read-only
+# funnel walk. Lives in internal/source/taskrabbit/ with X-CSRF-Token on mutations.
+# - Magic (console.api.getmagic.com/api/v1, x-api-key): send/call/reply/track.
+# Lives in internal/source/magic/ (different base_url + auth than the primary).
+# - Cross-source verbs (dispatch/track/spend) and transcendence (all-in pricing,
+# compare/best, hire, verified cancel, spend cap, watch, rebook).
+
+auth:
+ type: cookie
+ header: Cookie
+ cookie_domain: .taskrabbit.com
+ # Real Rails auth cookie is `tr6` (host-only on www.taskrabbit.com); the sniff
+ # report's `session` cookie is a 10-char decoy, not the auth token. Verified
+ # live 2026-07-03: replay with tr6 + XSRF-TOKEN authenticates /api/v3 + tRPC.
+ cookies:
+ - tr6
+ - XSRF-TOKEN
+
+config:
+ format: toml
+ path: "~/.config/human-goat/config.toml"
+
+resources:
+ system:
+ description: TaskRabbit account bootstrap, metro, and dashboard reachability
+ endpoints:
+ bootstrap:
+ method: GET
+ path: /api/v3/web-client/bootstrap.json
+ description: Account bootstrap — metro (id, name, country), payment_method_types, stream_api_key. Used by doctor to resolve the account metro.
+ response:
+ type: object
+ item: Bootstrap
+ dashboard_counts:
+ method: GET
+ path: /api/v3/web-client/dashboard/counts
+ description: Dashboard tab counts (open tasks, messages, etc.)
+ response:
+ type: object
+
+ account:
+ description: The logged-in TaskRabbit client account profile
+ endpoints:
+ get:
+ method: GET
+ path: /api/v3/account.json
+ description: Get the authenticated account profile
+ response:
+ type: object
+ item: Account
+
+ categories:
+ description: TaskRabbit task categories and templates for the account metro
+ endpoints:
+ list:
+ method: GET
+ path: /api/v3/web-client/metro_task_template.json
+ description: List task templates for the account metro (title, category_name, category_id, default_template_id, top_category)
+ response:
+ type: array
+ item: Category
+ response_path: initial_task_templates
+
+ taskers:
+ description: Favorite, past, and suggested TaskRabbit Taskers
+ endpoints:
+ favorites:
+ method: GET
+ path: /api/v3/poster_rabbit_relationships/favorite
+ description: List your favorited Taskers (poster=client, rabbit=tasker)
+ params:
+ - name: page
+ type: int
+ required: false
+ description: Page number for pagination
+ response:
+ type: array
+ item: Tasker
+ response_path: items
+ pagination:
+ type: page
+ has_more_field: meta.total_pages
+ past:
+ method: GET
+ path: /api/v3/poster_rabbit_relationships/past
+ description: List Taskers you have hired before
+ params:
+ - name: page
+ type: int
+ required: false
+ description: Page number for pagination
+ response:
+ type: array
+ item: Tasker
+ response_path: items
+ pagination:
+ type: page
+ has_more_field: meta.total_pages
+ suggestions:
+ method: GET
+ path: /api/v3/web-client/dashboard/tasker_suggestions
+ description: Tasker suggestions for the account metro
+ response:
+ type: array
+ item: Tasker
+ response_path: items
+
+ invoices:
+ description: Invoice and payment-history flags
+ endpoints:
+ has_submitted:
+ method: GET
+ path: /api/v3/decline_invoice/has_submitted_invoices
+ description: Whether the account has submitted invoices (payment-history presence flag)
+ response:
+ type: object
+
+types:
+ Bootstrap:
+ fields:
+ - name: metro
+ type: object
+ - name: payment_method_types
+ type: array
+ - name: stream_api_key
+ type: string
+ - name: support_key
+ type: string
+ Account:
+ fields:
+ - name: id
+ type: string
+ - name: first_name
+ type: string
+ - name: last_name
+ type: string
+ - name: email
+ type: string
+ - name: metro_id
+ type: int
+ Category:
+ fields:
+ - name: title
+ type: string
+ - name: category_name
+ type: string
+ - name: category_id
+ type: int
+ - name: default_template_id
+ type: int
+ - name: top_category
+ type: bool
+ - name: fallback
+ type: bool
+ Tasker:
+ fields:
+ - name: id
+ type: string
+ - name: name
+ type: string
+ - name: hourly_rate
+ type: float
+ - name: rating
+ type: float
+ - name: review_count
+ type: int
+ - name: tasks_completed
+ type: int
+ - name: elite
+ type: bool
+ - name: metro_id
+ type: int
+ Booking:
+ fields:
+ - name: id
+ type: string
+ - name: source
+ type: string
+ - name: status
+ type: string
+ - name: title
+ type: string
+ - name: tasker_name
+ type: string
+ - name: hourly_rate
+ type: float
+ - name: all_in_rate
+ type: float
+ - name: scheduled_at
+ type: string
+ - name: created_at
+ type: string
+ Invoice:
+ fields:
+ - name: id
+ type: string
+ - name: booking_id
+ type: string
+ - name: amount
+ type: float
+ - name: hours
+ type: float
+ - name: created_at
+ type: string
+
+# Sync: which read endpoints feed the local SQLite store for offline track/spend/ranking.
+# The hand-coded tRPC (bookings) and Magic (remote tasks) adapters also write into the
+# shared store under source=taskrabbit / source=magic.
+sync:
+ cursor:
+ type: time_window
+ description: Bookings by date; tasker search cached per (category, metro, date). Categories rarely change.
+ resources:
+ - categories
+ - taskers
+ - account
+
+# MCP: expose the verbs as agent tools. stdio + http so hosted agents can reach it.
+mcp:
+ transport:
+ - stdio
+ - http
diff --git a/library/productivity/human-goat/.printing-press-patches/.gitkeep b/library/productivity/human-goat/.printing-press-patches/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/library/productivity/human-goat/.printing-press-patches/human-goat-greptile-review-fixes.json b/library/productivity/human-goat/.printing-press-patches/human-goat-greptile-review-fixes.json
new file mode 100644
index 0000000000..1473fccbbf
--- /dev/null
+++ b/library/productivity/human-goat/.printing-press-patches/human-goat-greptile-review-fixes.json
@@ -0,0 +1,21 @@
+{
+ "schema_version": 2,
+ "id": "human-goat-greptile-review-fixes",
+ "applied_at": "2026-07-04",
+ "base_run_id": "20260703-152050-dff84056",
+ "base_printing_press_version": "4.27.1",
+ "summary": "Correctness + safety hardening of the live TaskRabbit/Magic commands from code review: slot-duration-aware spend cap (float hours, ceil, 2h-min); cancel pages all bookings, fails fast on missing CSRF, and reports refundability from the actual 24h window; hire aborts checkout on zero availability, surfaces the booked/requested date with a substitution warning, and reports the confirmed charge when returned (else labels the estimate); spend prefers billed amounts, applies the all-in fee transform x duration with a --state carve-out; status is a real cross-source inbox (paginated TaskRabbit + locally-persisted Magic refreshed live) and runs bare; watch binds its poll context to --max-wait and maps its own deadline to a clean exit.",
+ "reason": "TaskRabbit bills by the booked slot duration and honors a per-Tasker 2-hour minimum, stores base hourly rate (not all-in) in booking rows, requires X-CSRF-Token on mutations, and paginates task lists at 50/page; the generated bodies hardcoded a 2h cap estimate, single-page lookups, a soft CSRF path, and base-rate spend totals.",
+ "files": [
+ "internal/cli/hire.go",
+ "internal/cli/cancel.go",
+ "internal/cli/spend.go",
+ "internal/cli/status.go",
+ "internal/cli/watch.go",
+ "internal/cli/magicstore.go",
+ "internal/cli/send.go",
+ "internal/cli/call.go",
+ "internal/cli/dispatch.go"
+ ],
+ "validated_outcome": "go build/vet/test + full live dogfood (120/0) pass; Greptile PR #1435 reached 5/5 (Production ready) with all threads resolved."
+}
diff --git a/library/productivity/human-goat/.printing-press-patches/human-goat-learning-cmd-dogfood-compat.json b/library/productivity/human-goat/.printing-press-patches/human-goat-learning-cmd-dogfood-compat.json
new file mode 100644
index 0000000000..2d09228e74
--- /dev/null
+++ b/library/productivity/human-goat/.printing-press-patches/human-goat-learning-cmd-dogfood-compat.json
@@ -0,0 +1,11 @@
+{
+ "schema_version": 2,
+ "id": "human-goat-learning-cmd-dogfood-compat",
+ "applied_at": "2026-07-04",
+ "base_run_id": "20260703-152050-dff84056",
+ "base_printing_press_version": "4.27.1",
+ "summary": "Add pp:happy-args to generated learning-loop commands (teach, teach-pattern, teach-playbook, playbook amend) and emit JSON under --json for teach even when --quiet defaults true.",
+ "reason": "dogfood --live --level full synthesizes incomplete args for these required-flag commands and applies a json_fidelity check to teach, which is silent-by-default; the publish live gate fails without valid happy-args and a JSON-on-request path. Root cause is a generator/dogfood machine bug (framework learning commands should carry happy-args or be skipped in the live matrix) filed as a retro.",
+ "files": ["internal/cli/teach.go", "internal/cli/teach_playbook.go"],
+ "validated_outcome": "dogfood --live --level full: 0 failures (was 9)."
+}
diff --git a/library/productivity/human-goat/.printing-press-pii-polish.json b/library/productivity/human-goat/.printing-press-pii-polish.json
new file mode 100644
index 0000000000..61f630f6e5
--- /dev/null
+++ b/library/productivity/human-goat/.printing-press-pii-polish.json
@@ -0,0 +1,6 @@
+{
+ "timestamp": "2026-07-04T02:06:27.998803Z",
+ "cli_dir": "\u003ccli-dir\u003e/human-goat",
+ "findings": null,
+ "findings_count_before": 0
+}
diff --git a/library/productivity/human-goat/.printing-press-tools-polish.json b/library/productivity/human-goat/.printing-press-tools-polish.json
new file mode 100644
index 0000000000..ca4a89443a
--- /dev/null
+++ b/library/productivity/human-goat/.printing-press-tools-polish.json
@@ -0,0 +1,15 @@
+{
+ "timestamp": "2026-07-04T01:52:12.663592Z",
+ "cli_dir": "\u003ccli-dir\u003e/human-goat-pp-cli",
+ "findings": [
+ {
+ "kind": "thin-short",
+ "command": "list",
+ "file": "teach.go",
+ "line": 692,
+ "evidence": "List recorded learnings",
+ "status": "accepted",
+ "note": "Brief but precise; teach.go is a DO-NOT-EDIT generated learning-loop file, so a hand-edit would be wiped on regen. Short accurately states the action; generator-template refinement is a retro candidate."
+ }
+ ]
+}
diff --git a/library/productivity/human-goat/.printing-press.json b/library/productivity/human-goat/.printing-press.json
new file mode 100644
index 0000000000..c1480bef79
--- /dev/null
+++ b/library/productivity/human-goat/.printing-press.json
@@ -0,0 +1,68 @@
+{
+ "schema_version": 2,
+ "generated_at": "2026-07-04T01:53:35.522689Z",
+ "printing_press_version": "4.27.1",
+ "api_name": "human-goat",
+ "display_name": "Human-Goat",
+ "cli_name": "human-goat-pp-cli",
+ "creator": {
+ "handle": "mvanhorn",
+ "name": "Matt Van Horn"
+ },
+ "owner": "mvanhorn",
+ "printer": "mvanhorn",
+ "printer_name": "Matt Van Horn",
+ "spec_path": "human-goat-spec.yaml",
+ "spec_format": "internal",
+ "spec_checksum": "sha256:d13bff25e9d8da581b5f61422d5eb6af8332ef8ed558dd6106f973f96c1a3bc9",
+ "run_id": "20260703-152050-dff84056",
+ "category": "productivity",
+ "description": "Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary.",
+ "mcp_binary": "human-goat-pp-mcp",
+ "mcp_tool_count": 8,
+ "mcp_ready": "partial",
+ "api_version": "0.1.0",
+ "auth_type": "cookie",
+ "novel_features": [
+ {
+ "name": "Autonomous soup-to-nuts hire",
+ "command": "hire",
+ "description": "Say the job and the date; goat searches, ranks by review quality and honest all-in price, and checks out against the card on file with no prompt."
+ },
+ {
+ "name": "Verified cancel (the safety gate)",
+ "command": "cancel",
+ "description": "Cancels a booking and confirms it landed by re-reading status, reporting whether it was inside the free window and any fee."
+ },
+ {
+ "name": "Spend cap",
+ "command": "hire",
+ "description": "Refuses to check out when the computed all-in total exceeds a configurable ceiling, printing the total and the cap."
+ },
+ {
+ "name": "All-in price",
+ "command": "best",
+ "description": "Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate, honoring the CA/MA service-fee-only rule."
+ },
+ {
+ "name": "Cross-source dispatch",
+ "command": "dispatch",
+ "description": "Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override."
+ },
+ {
+ "name": "Cross-source spend analytics",
+ "command": "spend",
+ "description": "SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month, using true effective all-in $/hr for TaskRabbit."
+ },
+ {
+ "name": "Availability watch",
+ "command": "watch",
+ "description": "Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens."
+ },
+ {
+ "name": "Unified in-flight inbox",
+ "command": "status",
+ "description": "One list of every in-flight task across TaskRabbit bookings and Magic requests, joined on the common task model and sorted by state."
+ }
+ ]
+}
diff --git a/library/productivity/human-goat/AGENTS.md b/library/productivity/human-goat/AGENTS.md
new file mode 100644
index 0000000000..27cf08de96
--- /dev/null
+++ b/library/productivity/human-goat/AGENTS.md
@@ -0,0 +1,66 @@
+# Human-Goat Printed CLI Agent Guide
+
+This directory is a generated `human-goat-pp-cli` printed CLI. It was produced by [CLI Printing Press](https://github.com/mvanhorn/cli-printing-press), so treat systemic fixes as upstream Printing Press fixes first. Keep local edits narrow and document why a generated-tree patch belongs here.
+
+## Local Operating Contract
+
+Start by asking the generated CLI for current runtime truth:
+
+```bash
+human-goat-pp-cli doctor --json
+human-goat-pp-cli agent-context --pretty
+```
+
+Use runtime discovery instead of relying on a copied command list:
+
+```bash
+human-goat-pp-cli which "" --json
+human-goat-pp-cli --help
+```
+
+Add `--agent` to command invocations for JSON, compact output, non-interactive defaults, no color, and confirmation-safe scripting:
+
+```bash
+human-goat-pp-cli --agent
+```
+
+Before running an unfamiliar command that may mutate remote state, inspect its help and prefer a dry run:
+
+```bash
+human-goat-pp-cli --help
+human-goat-pp-cli --dry-run --agent
+```
+
+Use `--yes --no-input` only after the target, arguments, and side effects are clear.
+
+## Self-Learning Loop
+
+This CLI ships a self-capturing teach/recall loop backed by the local SQLite store. The CLI journals every invocation, derives `flag_alias` candidates from failed-flag + corrected-retry pairs, and synthesizes a playbook candidate when a family is taught without one - no manual failure bookkeeping. The agent's role is judgment:
+
+1. On a new user question, call `human-goat-pp-cli recall "" --agent` FIRST. If `found=true` and the top result has `entity_match == "exact"` and `confidence >= 2`, skip discovery and go straight to the live fetch for the returned resource IDs. If the store is cold (recall finds nothing and `learnings list` and `learnings candidates` are both empty), skip recall for the rest of the session.
+2. When the envelope carries a `candidates` section (warning `candidates_present`), candidates are try-then-confirm, never facts: follow each candidate's two-step `next_action` verbatim (trial command first, then `learnings confirm ` only after the trial verified the behavior), and reject wrong ones with `learnings reject `. Never re-teach something recall surfaced as a candidate; confirm or reject it instead.
+3. After answering, always fire `human-goat-pp-cli teach --query "" --resource --resource-type &` in the background - teaching is unconditional and is the anchor that triggers playbook synthesis. Teach the structural question with identifiers stripped (no names, emails, phone numbers, account ids); the CLI warns on obvious PII shapes but does not block.
+4. Use `learnings list` to inspect taught rows, `learnings forget ""` to undo a bad teach, `learnings candidates` for the full open candidate set, and `learnings stats` for the loop's local metrics. `teach-pattern` and `teach-lookup` install manual generalization rules when one teach should cover a whole family (e.g. one country alias unlocks every per-country query).
+5. If `learnings confirm` is an unknown command, you are driving an older binary - ignore the candidates guidance and keep the rest of the flow.
+
+Annotations: `recall`, `learnings list`, `learnings candidates`, and `learnings stats` carry `mcp:read-only=true`; `teach`, `teach-playbook`, `playbook amend`, and `learnings confirm` carry `mcp:local-write=true` (writes land only in the CLI's own local store); `learnings forget` and `learnings reject` keep honest may-write/destructive defaults; `teach-pattern` and `teach-lookup` are unannotated.
+
+### Success definition
+
+Measurement is local-only: the `learn_events` table and `learnings stats`; nothing leaves this machine. Judge the loop on recall hit rate and teach-to-reuse at a minimum denominator of 50+ recall events. Near-zero rates at that denominator mean the loop is not earning its keep for this CLI - surface that in retros. An empty or thin events table means insufficient adoption, not failure.
+
+The store's schema stamp is one-way: once this binary opens the database, an older binary refuses it (README.md carries the upgrade note).
+
+Disable the loop with `--no-learn` per-invocation or `HUMAN_GOAT_NO_LEARN=true` for the whole session - useful for deterministic agent flows that don't want a learning row to silently change subsequent query results.
+
+For install, auth, examples, and longer product guidance, read `README.md` and `SKILL.md`. This file intentionally stays small so repo-local agents get invariant local guidance without duplicating the generated docs.
+
+## Release Ledger
+
+`CHANGELOG.md` and `.printing-press-release.json` are the public library's per-CLI release ledger. Fresh prints may carry blank skeletons, but the final `YYYY.M.N` CLI release version is assigned only after a publish PR merges in `mvanhorn/printing-press-library`. Do not hand-bump those files or edit `var version = ...` for release bookkeeping; preserve existing ledger files on reprint and let the library workflow stamp the next release.
+
+## Local Customizations
+
+This directory is **generated output** -- a fresh print can overwrite the whole tree, so ad-hoc hand-edits don't survive on their own. If you modify the generated code, record each change under `.printing-press-patches/` (parallel to `.printing-press.json`) so a regen carries the intent forward instead of silently dropping it.
+
+The entry shape, and the altitude to write it at -- a durable reprint-guard, not a changelog -- live in the public library's `AGENTS.md`, which is the single source of truth; this guide intentionally doesn't duplicate them.
diff --git a/library/productivity/human-goat/CHANGELOG.md b/library/productivity/human-goat/CHANGELOG.md
new file mode 100644
index 0000000000..e75331a55f
--- /dev/null
+++ b/library/productivity/human-goat/CHANGELOG.md
@@ -0,0 +1,4 @@
+# Changelog
+
+This file is maintained by printing-press-library release automation. Do not hand-edit release sections in normal PRs.
+
diff --git a/library/productivity/human-goat/CLAUDE.md b/library/productivity/human-goat/CLAUDE.md
new file mode 100644
index 0000000000..43c994c2d3
--- /dev/null
+++ b/library/productivity/human-goat/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/library/productivity/human-goat/LICENSE b/library/productivity/human-goat/LICENSE
new file mode 100644
index 0000000000..a3e5360c8f
--- /dev/null
+++ b/library/productivity/human-goat/LICENSE
@@ -0,0 +1,191 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to the Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by the Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding any notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ Copyright 2026 Matt Van Horn and contributors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/library/productivity/human-goat/Makefile b/library/productivity/human-goat/Makefile
new file mode 100644
index 0000000000..43dc8c3e23
--- /dev/null
+++ b/library/productivity/human-goat/Makefile
@@ -0,0 +1,26 @@
+.PHONY: build test lint install clean
+
+BIN_EXT := $(if $(filter windows,$(shell go env GOOS)),.exe,)
+
+build:
+ go build -o bin/human-goat-pp-cli$(BIN_EXT) ./cmd/human-goat-pp-cli
+
+test:
+ go test ./...
+
+lint:
+ golangci-lint run
+
+install:
+ go install ./cmd/human-goat-pp-cli
+
+clean:
+ rm -rf bin/
+
+build-mcp:
+ go build -o bin/human-goat-pp-mcp$(BIN_EXT) ./cmd/human-goat-pp-mcp
+
+install-mcp:
+ go install ./cmd/human-goat-pp-mcp
+
+build-all: build build-mcp
diff --git a/library/productivity/human-goat/NOTICE b/library/productivity/human-goat/NOTICE
new file mode 100644
index 0000000000..6c3461823d
--- /dev/null
+++ b/library/productivity/human-goat/NOTICE
@@ -0,0 +1,11 @@
+human-goat-pp-cli
+Copyright 2026 Matt Van Horn and contributors
+
+Created by Matt Van Horn (@mvanhorn).
+
+This CLI was generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press)
+by Matt Van Horn and Trevin Chow. The Non-Obvious Insight, domain archetype detection,
+workflow commands, and behavioral insight commands were produced by the printing press's
+creative vision engine.
+
+CLI Printing Press is licensed separately under the MIT License.
diff --git a/library/productivity/human-goat/README.md b/library/productivity/human-goat/README.md
new file mode 100644
index 0000000000..c5c2ec623e
--- /dev/null
+++ b/library/productivity/human-goat/README.md
@@ -0,0 +1,415 @@
+# Human-Goat CLI
+
+**Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary.**
+
+goat unifies two human networks behind a common task model: TaskRabbit for in-person local labor and Magic for remote errands. Its headline is hands-off checkout on TaskRabbit against the card on file — searched, ranked by honest all-in price and review quality, and booked with no prompt — made safe by a spend cap and a cancel command that verifies the cancellation actually landed.
+
+## Install
+
+The recommended path installs both the `human-goat-pp-cli` binary and the `pp-human-goat` agent skill (Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, and other agents supported by the upstream [`skills`](https://github.com/vercel-labs/skills) CLI) in one shot:
+
+```bash
+npx -y @mvanhorn/printing-press-library install human-goat
+```
+
+For CLI only (no skill):
+
+```bash
+npx -y @mvanhorn/printing-press-library install human-goat --cli-only
+```
+
+For skill only — installs the skill into the same agents as the default command above, but skips the CLI binary (use this to update or reinstall just the skill):
+
+```bash
+npx -y @mvanhorn/printing-press-library install human-goat --skill-only
+```
+
+To constrain the skill install to one or more specific agents (repeatable — agent names match the [`skills`](https://github.com/vercel-labs/skills) CLI):
+
+```bash
+npx -y @mvanhorn/printing-press-library install human-goat --agent claude-code
+npx -y @mvanhorn/printing-press-library install human-goat --agent claude-code --agent codex
+```
+
+### Without Node (Go fallback)
+
+If `npx` isn't available (no Node, offline), install the CLI directly via Go (requires Go 1.26.4 or newer):
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/productivity/human-goat/cmd/human-goat-pp-cli@latest
+```
+
+This installs the CLI only — no skill.
+
+### Pre-built binary
+
+Download a pre-built binary for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/human-goat-current). On macOS, clear the Gatekeeper quarantine: `xattr -d com.apple.quarantine `. On Unix, mark it executable: `chmod +x `.
+
+
+## Install for Hermes
+
+Install the CLI binary first. The installer writes binaries to a per-user managed bin directory by default: `$HOME/.local/bin` on macOS/Linux and `%LOCALAPPDATA%\Programs\PrintingPress\bin` on Windows.
+
+```bash
+npx -y @mvanhorn/printing-press-library install human-goat --cli-only
+```
+
+Then install the focused Hermes skill.
+
+From the Hermes CLI:
+
+```bash
+hermes skills install mvanhorn/printing-press-library/cli-skills/pp-human-goat --force
+```
+
+Inside a Hermes chat session:
+
+```bash
+/skills install mvanhorn/printing-press-library/cli-skills/pp-human-goat --force
+```
+
+Restart the Hermes session or gateway if the newly installed skill is not visible immediately.
+
+## Install for OpenClaw
+Install both the CLI binary and the focused OpenClaw skill. The installer defaults binaries to a per-user bin directory (`$HOME/.local/bin` on macOS/Linux, `%LOCALAPPDATA%\Programs\PrintingPress\bin` on Windows):
+
+```bash
+npx -y @mvanhorn/printing-press-library install human-goat --agent openclaw
+```
+
+Restart the OpenClaw session or gateway if the newly installed skill is not visible immediately.
+
+## Use with Claude Desktop
+
+This CLI ships an [MCPB](https://github.com/modelcontextprotocol/mcpb) bundle — Claude Desktop's standard format for one-click MCP extension installs (no JSON config required).
+
+The bundle reuses your local browser session — set it up first if you haven't:
+
+```bash
+human-goat-pp-cli auth login --chrome
+```
+
+To install:
+
+1. Download the `.mcpb` for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/human-goat-current).
+2. Double-click the `.mcpb` file. Claude Desktop opens and walks you through the install.
+
+Requires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.
+
+
+Manual JSON config (advanced)
+
+If you can't use the MCPB bundle (older Claude Desktop, unsupported platform), install the MCP binary and configure it manually.
+
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/productivity/human-goat/cmd/human-goat-pp-mcp@latest
+```
+
+Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
+
+```json
+{
+ "mcpServers": {
+ "human-goat": {
+ "command": "human-goat-pp-mcp"
+ }
+ }
+}
+```
+
+
+
+## Authentication
+
+TaskRabbit auth is cookie replay from a logged-in Chrome session: run `human-goat-pp-cli auth login --chrome` to lift the session + XSRF-TOKEN cookies. Never a programmatic password login (the login form is reCAPTCHA-gated). Magic uses an x-api-key read from $MAGIC_API_KEY or ~/.magic/api_key.
+
+## Quick Start
+
+```bash
+# check both backends and resolve the account metro before doing anything
+human-goat-pp-cli doctor --dry-run
+
+# see the task templates available in your metro
+human-goat-pp-cli categories list --agent
+
+# find the best-value available Tasker with all-in pricing
+human-goat-pp-cli best "help moving" --on saturday --min-rating 4.9 --agent
+
+# render the confirm summary without booking; drop --dry-run to check out for real
+human-goat-pp-cli hire "help moving" --on saturday --min-rating 4.9 --max-total 200 --dry-run --lat 47.6062 --lng -122.3321
+
+```
+
+## Unique Features
+
+These capabilities aren't available in any other tool for this API.
+
+### Hands-off hiring
+- **`hire`** — Say the job and the date; goat searches, ranks by review quality and honest all-in price, and checks out against the card on file with no prompt.
+
+ _Reach for this when the user wants a task done, not a Tasker shortlist to review._
+
+ ```bash
+ human-goat-pp-cli hire "help moving" --on 2026-07-11 --min-rating 4.8 --max-total 200 --agent --lat 47.6062 --lng -122.3321
+ ```
+- **`cancel`** — Cancels a booking and confirms it landed by re-reading status, reporting whether it was inside the free window and any fee.
+
+ _This is the undo that makes autonomous checkout tolerable; always available after a hire._
+
+ ```bash
+ human-goat-pp-cli cancel task_abc123 --agent
+ ```
+- **`hire`** — Refuses to check out when the computed all-in total exceeds a configurable ceiling, printing the total and the cap.
+
+ _Use --max-total (or the config default) to cap autonomous spend before any booking is placed._
+
+ ```bash
+ human-goat-pp-cli hire movers --on saturday --min-rating 4.9 --max-total 150 --lat 47.6062 --lng -122.3321
+ ```
+
+### Honest pricing
+- **`best`** — Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate, honoring the CA/MA service-fee-only rule.
+
+ _Every price surface (search, compare, best, hire, spend) shows the real all-in rate, not the teaser._
+
+ ```bash
+ human-goat-pp-cli best "help moving" --on saturday --min-rating 4.9 --agent
+ ```
+- **`watch`** — Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens.
+
+ _Use when nothing good is available now and you want the first qualifying slot._
+
+ ```bash
+ human-goat-pp-cli watch movers --on saturday --max-rate 60
+ ```
+
+### One surface, two human networks
+- **`dispatch`** — Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override.
+
+ _Say what you want done and let goat pick the human network; force it with --via taskrabbit|magic._
+
+ ```bash
+ human-goat-pp-cli dispatch "call the dentist and reschedule my cleaning"
+ ```
+- **`spend`** — SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month, using true effective all-in $/hr for TaskRabbit.
+
+ _Answers where the money went across both human networks with fees folded in._
+
+ ```bash
+ human-goat-pp-cli spend --by source --agent
+ ```
+- **`status`** — One list of every in-flight task across TaskRabbit bookings and Magic requests, joined on the common task model and sorted by state.
+
+ _Use for a cross-source view of everything in flight; use 'tasks list' for TR-only and 'track ' for one Magic request._
+
+ ```bash
+ human-goat-pp-cli status --open --agent
+ ```
+
+## Recipes
+
+### Autonomous hire with a spend cap
+
+```bash
+human-goat-pp-cli hire "help moving" --on 2026-07-11 --min-rating 4.8 --max-total 200 --agent --lat 47.6062 --lng -122.3321
+```
+
+Searches, ranks by all-in price x reviews, checks out under the cap, and prints the booking id, Tasker, time, and total with no prompt.
+
+### Undo a booking and verify
+
+```bash
+human-goat-pp-cli cancel task_abc123 --agent
+```
+
+Cancels, re-reads status, and reports cancelled plus whether it was inside the free window and any fee.
+
+### Dispatch a remote errand
+
+```bash
+human-goat-pp-cli call 5209076052 "when does the jewelry store open"
+```
+
+Sends a phone-call task to Magic and returns a request id to track; the answer comes back in the conversation.
+
+### Narrow a verbose payload for an agent
+
+```bash
+human-goat-pp-cli taskers favorites --agent --select items.name,items.all_in_rate,items.rating
+```
+
+Returns only the fields an agent needs from a deeply nested Tasker list instead of the full payload.
+
+### Where did the money go
+
+```bash
+human-goat-pp-cli spend --by source --agent
+```
+
+Splits TaskRabbit vs Magic totals from the local store, with TaskRabbit rows using true all-in effective rate.
+
+## Usage
+
+Run `human-goat-pp-cli --help` for the full command reference and flag list.
+
+## Paths & environment variables
+
+This CLI separates local files into four path kinds:
+
+| Kind | Contents |
+|------|----------|
+| `config` | User-editable settings such as `config.toml` and saved profiles |
+| `data` | Durable local data: `credentials.toml`, `data.db`, cookies, browser-session proof files, and other auth sidecars |
+| `state` | Runtime state such as persisted queries, jobs, and `teach.log` |
+| `cache` | Regenerable HTTP/cache files |
+
+Each kind resolves independently. The ladder is:
+
+1. Per-kind env var: `HUMAN_GOAT_CONFIG_DIR`, `HUMAN_GOAT_DATA_DIR`, `HUMAN_GOAT_STATE_DIR`, or `HUMAN_GOAT_CACHE_DIR`
+2. `--home ` for this invocation
+3. `HUMAN_GOAT_HOME` for a flat relocated root
+4. XDG env vars: `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CACHE_HOME`
+5. Platform defaults matching existing installs
+
+For containers and agent sandboxes, prefer a single relocated root:
+
+```bash
+export HUMAN_GOAT_HOME=/srv/human-goat
+human-goat-pp-cli doctor
+```
+
+Under `HUMAN_GOAT_HOME=/srv/human-goat`, the four dirs resolve to `/srv/human-goat/config`, `/srv/human-goat/data`, `/srv/human-goat/state`, and `/srv/human-goat/cache`.
+
+MCP servers do not receive CLI flags from the host. Put relocation in the host `env` block:
+
+```json
+{
+ "mcpServers": {
+ "human-goat": {
+ "command": "human-goat-pp-mcp",
+ "env": {
+ "HUMAN_GOAT_HOME": "/srv/human-goat"
+ }
+ }
+ }
+}
+```
+
+Precedence matters in fleets: an ambient per-kind variable such as `HUMAN_GOAT_DATA_DIR` overrides an explicit `--home` for that kind. Use `HUMAN_GOAT_HOME` or the per-kind variables for durable fleet relocation; treat `--home` as the weaker per-invocation lever.
+
+Relocation is one-way. Unsetting `HUMAN_GOAT_HOME` does not move files back to platform defaults, and `doctor` cannot find credentials left under a former root. Move the files manually before unsetting relocation variables.
+
+Existing installs keep working because the platform-default rung matches the legacy layout. On the first auth write, stored secrets leave `config.toml` and are consolidated into `credentials.toml` under the data directory. Run `human-goat-pp-cli doctor --fail-on warn` to check path and credential-location warnings in automation.
+
+## Commands
+
+### account
+
+The logged-in TaskRabbit client account profile
+
+- **`human-goat-pp-cli account`** - Get the authenticated account profile
+
+### categories
+
+TaskRabbit task categories and templates for the account metro
+
+- **`human-goat-pp-cli categories`** - List task templates for the account metro (title, category_name, category_id, default_template_id, top_category)
+
+### invoices
+
+Invoice and payment-history flags
+
+- **`human-goat-pp-cli invoices`** - Whether the account has submitted invoices (payment-history presence flag)
+
+### system
+
+TaskRabbit account bootstrap, metro, and dashboard reachability
+
+- **`human-goat-pp-cli system bootstrap`** - Account bootstrap — metro (id, name, country), payment_method_types, stream_api_key. Used by doctor to resolve the account metro.
+- **`human-goat-pp-cli system dashboard-counts`** - Dashboard tab counts (open tasks, messages, etc.)
+
+### taskers
+
+Favorite, past, and suggested TaskRabbit Taskers
+
+- **`human-goat-pp-cli taskers favorites`** - List your favorited Taskers (poster=client, rabbit=tasker)
+- **`human-goat-pp-cli taskers past`** - List Taskers you have hired before
+- **`human-goat-pp-cli taskers suggestions`** - Tasker suggestions for the account metro
+
+
+### Self-learning loop
+
+This CLI caches per-question discovery so repeat queries skip the walk and structurally similar queries get answered via entity substitution. The loop also self-captures: every invocation is journaled locally, and failed-flag corrections plus fresh teaches surface as candidates on the next `recall` for confirm/reject judgment. Agents call `recall` before discovery and fire `teach &` after answering. See the `## Automatic learning` section in `SKILL.md` for the full protocol.
+
+- **`human-goat-pp-cli recall `** - Look up cached resources for a query before running discovery
+- **`human-goat-pp-cli teach`** - Record a query -> resource mapping (silent on success, safe to background with `&`)
+- **`human-goat-pp-cli learnings list`** - Inspect taught rows
+- **`human-goat-pp-cli learnings forget `** - Undo a teach
+- **`human-goat-pp-cli learnings candidates`** - List auto-captured candidates awaiting confirm/reject
+- **`human-goat-pp-cli learnings stats`** - Local loop metrics: recall hit rate, teach-to-reuse, playbook resolution, candidate counts
+- **`human-goat-pp-cli teach-pattern`** - Install a query/resource template up front
+- **`human-goat-pp-cli teach-lookup`** - Add an entity mapping (e.g. country code, team alias) for pattern substitution
+
+Pass `--no-learn` or set `HUMAN_GOAT_NO_LEARN=true` to disable the loop for deterministic flows.
+
+The local store's schema version stamp is one-way: once this version of `human-goat-pp-cli` opens the database, older binaries refuse it with a version error — upgrade the binary rather than downgrading.
+
+## Output Formats
+
+```bash
+# Human-readable table (default in terminal, JSON when piped)
+human-goat-pp-cli account
+
+# JSON for scripting and agents
+human-goat-pp-cli account --json
+
+# Filter to specific fields
+human-goat-pp-cli account --json --select id,name,status
+
+# Dry run — show the request without sending
+human-goat-pp-cli account --dry-run
+
+# Agent mode — JSON + compact + no prompts in one flag
+human-goat-pp-cli account --agent
+```
+
+## Agent Usage
+
+This CLI is designed for AI agent consumption:
+
+- **Non-interactive** - never prompts, every input is a flag
+- **Pipeable** - `--json` output to stdout, errors to stderr
+- **Filterable** - `--select id,name` returns only fields you need
+- **Previewable** - `--dry-run` shows the request without sending
+- **Read-only by default** - this CLI does not create, update, delete, publish, send, or mutate remote resources
+- **Offline-friendly** - sync/search commands can use the local SQLite store when available
+- **Agent-safe by default** - no colors or formatting unless `--human-friendly` is set
+
+Exit codes: `0` success, `2` usage error, `3` not found, `4` auth error, `5` API error, `7` rate limited, `10` config error.
+
+## Health Check
+
+```bash
+human-goat-pp-cli doctor
+```
+
+Verifies configuration, credentials, and connectivity to the API.
+
+## Configuration
+
+Run `human-goat-pp-cli doctor` to see the resolved config, data, state, and cache directories. The platform-default config path is `~/.config/human-goat/config.toml`; `--home`, `HUMAN_GOAT_HOME`, and per-kind env vars can relocate it.
+
+Static request headers can be configured under `headers`; per-command header overrides take precedence.
+
+## Troubleshooting
+**Authentication errors (exit code 4)**
+- Run `human-goat-pp-cli doctor` to check credentials
+**Not found errors (exit code 3)**
+- Check the resource ID is correct
+- Run the `list` command to see available items
+
+### API-specific
+- **doctor reports TaskRabbit unauthenticated** — run `human-goat-pp-cli auth login --chrome` while logged into taskrabbit.com in Chrome
+- **hire refuses with a spend-cap message** — raise --max-total or pick a cheaper Tasker; the printed all-in total exceeded the cap
+- **Magic task shows ONGOING forever** — the human's answer arrives in the conversation, not result; read it with `track ` which surfaces the conversation tail
diff --git a/library/productivity/human-goat/SKILL.md b/library/productivity/human-goat/SKILL.md
new file mode 100644
index 0000000000..99a825b512
--- /dev/null
+++ b/library/productivity/human-goat/SKILL.md
@@ -0,0 +1,531 @@
+---
+name: pp-human-goat
+description: "Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary. Trigger phrases: `hire a mover on saturday`, `book someone to assemble my furniture`, `call this number and ask`, `who did i hire before`, `cancel that booking`, `use human-goat`, `run goat`."
+author: "Matt Van Horn"
+license: "Apache-2.0"
+argument-hint: " [args] | install cli|mcp"
+allowed-tools: "Read Bash"
+metadata:
+ openclaw:
+ requires:
+ bins:
+ - human-goat-pp-cli
+ install:
+ - kind: go
+ bins: [human-goat-pp-cli]
+ module: github.com/mvanhorn/printing-press-library/library/productivity/human-goat/cmd/human-goat-pp-cli
+---
+
+# Human-Goat — Printing Press CLI
+
+## Prerequisites: Install the CLI
+
+This skill drives the `human-goat-pp-cli` binary. **You must verify the CLI is installed before invoking any command from this skill.** If it is missing, install it first:
+
+1. Install via the Printing Press installer. It defaults binaries to `$HOME/.local/bin` on macOS/Linux and `%LOCALAPPDATA%\Programs\PrintingPress\bin` on Windows:
+ ```bash
+ npx -y @mvanhorn/printing-press-library install human-goat --cli-only
+ ```
+2. Verify: `human-goat-pp-cli --version`
+3. Ensure the reported install directory is on `$PATH` for the agent/runtime that will invoke this skill.
+
+If the `npx` install fails (no Node, offline, etc.), fall back to a direct Go install (requires Go 1.26.4 or newer). This installs into `$GOPATH/bin` (default `$HOME/go/bin`), so add that directory to `$PATH` instead:
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/productivity/human-goat/cmd/human-goat-pp-cli@latest
+```
+
+If `--version` reports "command not found" after install, the runtime cannot see the binary directory on `$PATH`. Do not proceed with skill commands until verification succeeds.
+
+goat unifies two human networks behind a common task model: TaskRabbit for in-person local labor and Magic for remote errands. Its headline is hands-off checkout on TaskRabbit against the card on file — searched, ranked by honest all-in price and review quality, and booked with no prompt — made safe by a spend cap and a cancel command that verifies the cancellation actually landed.
+
+## When to Use This CLI
+
+Use goat when an agent should get a real-world task done end to end — hire a mover, assemble furniture, make a phone call, book something online — rather than surfacing options for a human to finish. It is strongest for autonomous TaskRabbit hiring with a safety net and for dispatching remote errands to Magic.
+
+## Anti-triggers
+
+Do not use this CLI for:
+- Do not use goat to edit the payment method on file — the card is managed in the TaskRabbit app.
+- Do not use goat for programmatic password login to either service — auth is cookie/api-key only.
+- Do not use goat to move money outside a task booking — no transfers, payouts, or standalone tipping.
+
+## Unique Capabilities
+
+These capabilities aren't available in any other tool for this API.
+
+### Hands-off hiring
+- **`hire`** — Say the job and the date; goat searches, ranks by review quality and honest all-in price, and checks out against the card on file with no prompt.
+
+ _Reach for this when the user wants a task done, not a Tasker shortlist to review._
+
+ ```bash
+ human-goat-pp-cli hire "help moving" --on 2026-07-11 --min-rating 4.8 --max-total 200 --agent --lat 47.6062 --lng -122.3321
+ ```
+- **`cancel`** — Cancels a booking and confirms it landed by re-reading status, reporting whether it was inside the free window and any fee.
+
+ _This is the undo that makes autonomous checkout tolerable; always available after a hire._
+
+ ```bash
+ human-goat-pp-cli cancel task_abc123 --agent
+ ```
+- **`hire`** — Refuses to check out when the computed all-in total exceeds a configurable ceiling, printing the total and the cap.
+
+ _Use --max-total (or the config default) to cap autonomous spend before any booking is placed._
+
+ ```bash
+ human-goat-pp-cli hire movers --on saturday --min-rating 4.9 --max-total 150 --lat 47.6062 --lng -122.3321
+ ```
+
+### Honest pricing
+- **`best`** — Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate, honoring the CA/MA service-fee-only rule.
+
+ _Every price surface (search, compare, best, hire, spend) shows the real all-in rate, not the teaser._
+
+ ```bash
+ human-goat-pp-cli best "help moving" --on saturday --min-rating 4.9 --agent
+ ```
+- **`watch`** — Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens.
+
+ _Use when nothing good is available now and you want the first qualifying slot._
+
+ ```bash
+ human-goat-pp-cli watch movers --on saturday --max-rate 60
+ ```
+
+### One surface, two human networks
+- **`dispatch`** — Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override.
+
+ _Say what you want done and let goat pick the human network; force it with --via taskrabbit|magic._
+
+ ```bash
+ human-goat-pp-cli dispatch "call the dentist and reschedule my cleaning"
+ ```
+- **`spend`** — SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month, using true effective all-in $/hr for TaskRabbit.
+
+ _Answers where the money went across both human networks with fees folded in._
+
+ ```bash
+ human-goat-pp-cli spend --by source --agent
+ ```
+- **`status`** — One list of every in-flight task across TaskRabbit bookings and Magic requests, joined on the common task model and sorted by state.
+
+ _Use for a cross-source view of everything in flight; use 'tasks list' for TR-only and 'track ' for one Magic request._
+
+ ```bash
+ human-goat-pp-cli status --open --agent
+ ```
+
+## Command Reference
+
+**account** — The logged-in TaskRabbit client account profile
+
+- `human-goat-pp-cli account` — Get the authenticated account profile
+
+**categories** — TaskRabbit task categories and templates for the account metro
+
+- `human-goat-pp-cli categories` — List task templates for the account metro (title, category_name, category_id, default_template_id, top_category)
+
+**invoices** — Invoice and payment-history flags
+
+- `human-goat-pp-cli invoices` — Whether the account has submitted invoices (payment-history presence flag)
+
+**system** — TaskRabbit account bootstrap, metro, and dashboard reachability
+
+- `human-goat-pp-cli system bootstrap` — Account bootstrap — metro (id, name, country), payment_method_types, stream_api_key.
+- `human-goat-pp-cli system dashboard-counts` — Dashboard tab counts (open tasks, messages, etc.)
+
+**taskers** — Favorite, past, and suggested TaskRabbit Taskers
+
+- `human-goat-pp-cli taskers favorites` — List your favorited Taskers (poster=client, rabbit=tasker)
+- `human-goat-pp-cli taskers past` — List Taskers you have hired before
+- `human-goat-pp-cli taskers suggestions` — Tasker suggestions for the account metro
+
+
+### Finding the right command
+
+When you know what you want to do but not which command does it, ask the CLI directly:
+
+```bash
+human-goat-pp-cli which ""
+```
+
+`which` resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code `0` means at least one match; exit code `2` means no confident match — fall back to `--help` or use a narrower query.
+
+## Recipes
+
+### Autonomous hire with a spend cap
+
+```bash
+human-goat-pp-cli hire "help moving" --on 2026-07-11 --min-rating 4.8 --max-total 200 --agent --lat 47.6062 --lng -122.3321
+```
+
+Searches, ranks by all-in price x reviews, checks out under the cap, and prints the booking id, Tasker, time, and total with no prompt.
+
+### Undo a booking and verify
+
+```bash
+human-goat-pp-cli cancel task_abc123 --agent
+```
+
+Cancels, re-reads status, and reports cancelled plus whether it was inside the free window and any fee.
+
+### Dispatch a remote errand
+
+```bash
+human-goat-pp-cli call 5209076052 "when does the jewelry store open"
+```
+
+Sends a phone-call task to Magic and returns a request id to track; the answer comes back in the conversation.
+
+### Narrow a verbose payload for an agent
+
+```bash
+human-goat-pp-cli taskers favorites --agent --select items.name,items.all_in_rate,items.rating
+```
+
+Returns only the fields an agent needs from a deeply nested Tasker list instead of the full payload.
+
+### Where did the money go
+
+```bash
+human-goat-pp-cli spend --by source --agent
+```
+
+Splits TaskRabbit vs Magic totals from the local store, with TaskRabbit rows using true all-in effective rate.
+
+## Auth Setup
+
+TaskRabbit auth is cookie replay from a logged-in Chrome session: run `human-goat-pp-cli auth login --chrome` to lift the session + XSRF-TOKEN cookies. Never a programmatic password login (the login form is reCAPTCHA-gated). Magic uses an x-api-key read from $MAGIC_API_KEY or ~/.magic/api_key.
+
+Run `human-goat-pp-cli doctor` to verify setup.
+
+## Agent Mode
+
+Add `--agent` to any command. Expands to: `--json --compact --no-input --no-color --yes`.
+
+- **Pipeable** — JSON on stdout, errors on stderr
+- **Filterable** — `--select` keeps a subset of fields. Dotted paths descend into nested structures; arrays traverse element-wise. Critical for keeping context small on verbose APIs:
+
+ ```bash
+ human-goat-pp-cli account --agent --select id,name,status
+ ```
+- **Previewable** — `--dry-run` shows the request without sending
+- **Offline-friendly** — sync/search commands can use the local SQLite store when available
+- **Non-interactive** — never prompts, every input is a flag
+- **Read-only** — do not use this CLI for create, update, delete, publish, comment, upvote, invite, order, send, or other mutating requests
+
+### Response envelope
+
+Commands that read from the local store or the API wrap output in a provenance envelope:
+
+```json
+{
+ "meta": {"source": "live" | "local", "synced_at": "...", "reason": "..."},
+ "results":
+}
+```
+
+Parse `.results` for data and `.meta.source` to know whether it's live or local. A human-readable `N results (live)` summary is printed to stderr only when stdout is a terminal AND no machine-format flag (`--json`, `--csv`, `--compact`, `--quiet`, `--plain`, `--select`) is set — piped/agent consumers and explicit-format runs get pure JSON on stdout.
+
+## Paths and state
+
+Agents should treat the CLI's path resolver as part of the runtime contract:
+
+- Use `--home ` for one invocation, or set `HUMAN_GOAT_HOME=` to relocate all four path kinds under one root.
+- Use per-kind env vars only when a specific kind must diverge: `HUMAN_GOAT_CONFIG_DIR`, `HUMAN_GOAT_DATA_DIR`, `HUMAN_GOAT_STATE_DIR`, `HUMAN_GOAT_CACHE_DIR`.
+- Resolution order is per-kind env var, `--home`, `HUMAN_GOAT_HOME`, XDG (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CACHE_HOME`), then platform defaults.
+- `config` contains settings like `config.toml` and profiles. `data` contains `credentials.toml`, `data.db`, cookies, and auth sidecars. `state` contains persisted queries, jobs, and `teach.log`. `cache` contains regenerable HTTP/cache files.
+- Stored secrets live in `credentials.toml` under the data dir. Existing legacy `config.toml` secrets are read for compatibility and leave `config.toml` on the first auth write.
+- Run `human-goat-pp-cli doctor --fail-on warn` to surface path and credential-location warnings. `agent-context` exposes a schema v4 `paths` block for agents that need the resolved dirs.
+- For MCP, pass relocation through the MCP host config. The MCP binary does not inherit CLI flags:
+
+ ```json
+ {
+ "mcpServers": {
+ "human-goat": {
+ "command": "human-goat-pp-mcp",
+ "env": {
+ "HUMAN_GOAT_HOME": "/srv/human-goat"
+ }
+ }
+ }
+ }
+ ```
+
+Fleet precedence: an inherited per-kind env var overrides an explicit `--home` for that kind. Use `HUMAN_GOAT_HOME` or per-kind vars as durable fleet levers, and use `--home` only for a single invocation. Relocation is not reversible by unsetting env vars; move files manually before clearing `HUMAN_GOAT_HOME`, or `doctor` will not find credentials left under the former root.
+
+## Automatic learning
+
+This CLI ships a self-capturing learning loop. The CLI does its own bookkeeping: every invocation is journaled locally, a failed flag followed by a corrected retry auto-derives a `flag_alias` candidate, and a `teach` on a query family without a playbook auto-synthesizes a `playbook_candidate` from the session's journal. Your job is judgment only: `recall` first, act on surfaced candidates, `teach` the final answer, `playbook amend` when you observe a correction. You never record failures by hand.
+
+### Step 1: `recall` before any discovery
+
+Before list/search/drill commands on a new user question, run:
+
+```bash
+human-goat-pp-cli recall "" --agent
+```
+
+The response envelope:
+
+```json
+{
+ "query": "...",
+ "normalized": "",
+ "query_entities": ["..."],
+ "found": true | false,
+ "match_score": 0.0,
+ "results": [
+ { "resource_id": "...", "resource_type": "...", "venue": "...",
+ "confidence": 2, "entity_match": "exact|partial|unknown",
+ "source": "taught|preseed|pattern", "warnings": ["..."] }
+ ],
+ "mismatches": [ /* only when --debug-mismatches */ ],
+ "warnings": [ /* top-level */ ],
+ "candidates": [
+ { "id": 12, "class": "flag_alias | playbook_candidate",
+ "summary": "...", "sightings": 3, "last_seen": "...",
+ "rationale": "...",
+ "next_action": ["", "human-goat-pp-cli learnings confirm 12"] }
+ ],
+ "playbook": {
+ "query_family": "...",
+ "playbook": {
+ "steps": [ { "cmd": "", "purpose": "..." } ],
+ "entity_slots": ["$ENTITY"],
+ "expected_tool_calls": 3
+ },
+ "slots_resolved": { "$ENTITY": { "token": "", "canonical": "" } },
+ "notes": ""
+ },
+ "notes": ""
+}
+```
+
+Empty-store short-circuit: if the store has no learnings, playbooks, or candidates yet (recall finds nothing and `learnings list` and `learnings candidates` are both empty), skip recall for the rest of this session instead of taxing every query; resume recall-first once something has been taught.
+
+### Step 2: decision tree
+
+Read `candidates`, `playbook`, `notes`, `results[0]`, and warnings in that order:
+
+```
+if Candidates present (warnings include "candidates_present"):
+ -> candidates are try-then-confirm, never facts. Follow each candidate's
+ two-step next_action verbatim: run the trial command first, then run
+ `learnings confirm ` only after the trial verified the behavior.
+ Reject a wrong candidate with `learnings reject `.
+ -> NEVER re-teach something recall surfaced as a candidate; confirm or
+ reject that candidate instead of teaching a duplicate.
+ -> candidates ride alongside playbooks and resource hits, not instead of
+ them; continue with the branches below after acting on them.
+
+if Playbook present:
+ -> READ Playbook.notes verbatim FIRST (workarounds + gotchas the CLI surface doesn't expose)
+ -> replay Playbook.steps in order, substituting Playbook.slots_resolved entries
+ for the entity slot tokens. If a step's slot is unresolved, fall back to
+ discovery for that step only.
+ -> the Playbook's expected_tool_calls is a budget; if you find yourself running
+ materially more, record the divergence via `human-goat-pp-cli playbook amend`
+ at end-of-session.
+
+elif Notes present (no Playbook):
+ -> read Notes verbatim before any discovery step; they carry known gotchas
+ for this query family even when no structured choreography exists yet.
+
+elif Found AND Results[0].EntityMatch == "exact" AND Results[0].Confidence >= 2:
+ -> skip discovery; fetch live data for Results[*].ResourceID in parallel
+
+elif Found AND Results[0].EntityMatch == "partial":
+ -> candidate hint, NOT a hit; read the resource title to validate before trusting
+
+elif (any row in Mismatches[] when --debug-mismatches was passed):
+ -> treat as cold start; the stored learning is for a different entity
+ (different canonical resolved from query_entities)
+
+else: // Found == false, no playbook, no notes
+ -> cold start; run discovery normally; teach the answer afterward (Step 4).
+ If the family has no playbook yet, that teach auto-synthesizes a
+ playbook candidate from this session's journal - you do not need to
+ record one by hand.
+```
+
+Playbook and Notes are orthogonal to the per-resource path. A recall response can carry both a Playbook AND a `Results[]` hit - use both: the Playbook tells you which choreography to run; the resource hits short-circuit specific steps. Default to skipping `mismatches`; pass `--debug-mismatches` only when investigating cold-start surprises.
+
+Candidate judgment details: `learnings confirm ` prints the candidate's full payload before materializing it - check that the printed payload matches the behavior you verified. `learnings reject ` tombstones the derivation signature so the same candidate does not resurface. The envelope carries only the few candidates worth acting on now; `human-goat-pp-cli learnings candidates` lists the full open set.
+
+Graceful degradation: if `learnings confirm` is an unknown command, you are driving an older binary - ignore the candidates guidance and follow the rest of the protocol.
+
+### Step 3: always read `warnings`
+
+- `low_confidence`: row exists at `confidence<2`. Treat as a hint, not a skip-discovery hit.
+- `resource_not_in_store`: the local store doesn't have the resource the learning points at. The match validator couldn't classify entities — direct-fetch and re-evaluate.
+- `cross_alias_match` (per-result): the row was taught under a different alias and matched the live query's canonical via `entity_lookups` (e.g., a "USA" teach satisfying a "United States" recall). Trust the resource_id.
+- `similar_shape_different_entity:` (top-level): a structurally matching row exists but its canonical entity differs from the live query's. Treated as cold start; the warning carries the conflicting canonical as a hint, but the row is NOT promoted into Results.
+- `ambiguous_alias` (top-level): a single query entity resolved to multiple canonicals (e.g., "Cards" → Arizona Cardinals + St. Louis Cardinals). Surface the ambiguity from context before committing to a resource.
+- `candidates_present` (top-level): the envelope carries a `candidates` section. Handle it via the candidates branch in Step 2 before anything else.
+- `lookup_refresh_available` (top-level): an entity in the query has no lookup row yet, but synced data could provide one. Run `human-goat-pp-cli sync` to refresh entity lookups.
+- Top-level `no_learnings_for_query_family`: the table had no rows above the Jaccard floor. Pure cold start.
+
+### Step 4: `teach &` after finalizing your response - always
+
+Teaching is unconditional. After resolving a query the store could not answer, background-teach the final resource mapping - no call-count threshold, no judging whether it was "worth" learning. The teach is the anchor of the loop: it triggers playbook synthesis for a family without a playbook, and same-referent phrasings fold into one family so near-duplicate teaches do not fragment the store. Fire it after assembling your user-facing response but BEFORE emitting it, with a shell `&` so the call returns immediately:
+
+```bash
+human-goat-pp-cli teach --query "" --resource-type --resource --resource
+# (append shell `&` to background it)
+```
+
+Silent on success. Errors only land in `teach.log` under the resolved state dir. Teach the **most specific** resource - if the user asked a broad question and you walked through parent records to find the specific answer, teach the leaf id, not the parent. The CLI uses seeded `entity_lookups` for cross-alias resolution at recall time, so a teach under one alias (e.g., "Niners") satisfies future queries under another alias (e.g., "49ers", "San Francisco") automatically.
+
+PII rule: teach the structural question with identifiers stripped - never include names, emails, phone numbers, account ids, or other personal identifiers in taught queries or notes. The CLI scans teach queries for obvious email/phone shapes and warns, but does not block; strip before teaching rather than relying on the warning.
+
+### Step 5: playbooks - optional flags, automatic synthesis
+
+You do not need to decide whether a session "deserves" a playbook: a teach on a family without one auto-synthesizes a `playbook_candidate` from the session's journal, and the next session judges it via confirm/reject. Attach explicit playbook flags only when you already hold choreography worth recording verbatim - workarounds the CLI didn't surface (silently-dropped flags, undocumented params, pagination tricks, payload gotchas). Prefer the **integrated one-call form** - record the resource learning and the playbook in the same `teach` invocation:
+
+```bash
+# Common case: record both the resource learning AND the playbook in one call.
+human-goat-pp-cli teach \
+ --query "" \
+ --resource \
+ --playbook-file ~/playbooks/.json \
+ --playbook-notes-file ~/playbooks/-notes.md
+# (append shell `&` to background it)
+
+# Alternate: playbook-only (no resource to record alongside).
+human-goat-pp-cli teach-playbook \
+ --query "" \
+ --playbook-file ~/playbooks/.json \
+ --notes-file ~/playbooks/-notes.md
+```
+
+Playbook files are JSON with `steps`, `entity_slots`, `expected_tool_calls`. Notes files are markdown carrying the gotchas verbatim. File-free callers (MCP-only agents) pass the same content inline: `--playbook-json` and `--playbook-notes` on the integrated `teach` form, `--playbook-json` and `--notes` on `teach-playbook`. On the integrated `teach` form, the playbook flags are optional - omit them entirely for a resource-only teach. On the standalone `teach-playbook` form, at least one of the playbook and notes flags must be set; both empty is rejected. Playbooks are keyed on the structural query family (entities stripped) so a recipe taught from one entity-shaped query applies to every other query of the same shape, with `slots_resolved` binding the live query's canonical at recall time.
+
+When you DO find a playbook on a future recall, treat it as ground truth: replay the steps with `slots_resolved` substitutions, skip the discovery that the choreography already documents, and read `notes` before any step.
+
+### Step 6: `playbook amend &` when your debug response identifies a correction
+
+If your debug-protocol response identifies a concrete correction the notes or playbook should know — a workaround, an undocumented endpoint shape, a stale field name, observed schema drift, an empty-payload fallback — fire `playbook amend` BEFORE emitting your user-facing response. Same fire-and-forget posture as `teach`.
+
+```bash
+human-goat-pp-cli playbook amend \
+ --query "" \
+ --add-note ""
+# (append shell `&` to background it)
+```
+
+What counts as worth amending: a behavior you OBSERVED this session that future-you would benefit from knowing. Examples worth amending:
+
+- A workaround for a CLI surface that silently drops or misorders a flag.
+- An undocumented endpoint shape (response wrapped in `{meta, results}`, payload nested two levels deeper than the docs claim).
+- Observed schema drift (a field renamed, an index that shifted between seasons, a category label that the API now returns lower-cased).
+
+What does NOT belong in notes:
+
+- The year-specific or entity-specific answer to the user's question. That's the response, not a learning.
+- Per-team / per-athlete / per-row data the playbook already retrieves at runtime.
+- Statements that paraphrase what the existing notes already say.
+
+The amend command appends to the family's existing notes with a timestamped marker (`[amend YYYY-MM-DDTHH:MMZ]: `). Multiple amends accumulate; the audit trail is visible. If no playbook exists yet for the family, amend creates a notes-only one (so cold-start corrections still land).
+
+#### PII discipline for amend notes
+
+`playbook amend` notes are designed to potentially flow upstream as shared knowledge in future versions of the Printing Press. Keep them clean of user-identifying content so the upstream-contribution path stays open without retroactive scrubbing:
+
+- **Do NOT embed** paths to user filesystems, personal API keys or tokens, user email addresses, user GitHub handles, or specific query histories tied to a single user.
+- **Acceptable**: endpoint shapes, undocumented field names, API gotchas, observed schema drift, workarounds for CLI surfaces, generalizable pagination or retry tactics.
+
+If a correction is only meaningful with user-specific context, it belongs in a personal note, not in the playbook amend.
+
+### Measuring the loop
+
+`human-goat-pp-cli learnings stats` reports recall hit rate, teach-to-reuse, playbook resolution rate, and candidate confirm/reject counts from the local `learn_events` table. Rates are null until they have a denominator; everything stays on this machine. Use it to check whether the loop is earning its keep for this CLI.
+
+### Disabling learning
+
+- `--no-learn` on a single command short-circuits both `recall` and the `teach` write path. Use for deterministic agent flows or tests that must not be affected by accumulated learnings.
+- `HUMAN_GOAT_NO_LEARN=true` in the environment globally disables the pipeline.
+
+## Agent Feedback
+
+When you (or the agent) notice something off about this CLI, record it:
+
+```
+human-goat-pp-cli feedback "the --since flag is inclusive but docs say exclusive"
+human-goat-pp-cli feedback --stdin < notes.txt
+human-goat-pp-cli feedback list --json --limit 10
+```
+
+Entries are stored locally as `feedback.jsonl` under the resolved data dir. They are never POSTed unless `HUMAN_GOAT_FEEDBACK_ENDPOINT` is set AND either `--send` is passed or `HUMAN_GOAT_FEEDBACK_AUTO_SEND=true`. Default behavior is local-only.
+
+Write what *surprised* you, not a bug report. Short, specific, one line: that is the part that compounds.
+
+## Output Delivery
+
+Every command accepts `--deliver `. The output goes to the named sink in addition to (or instead of) stdout, so agents can route command results without hand-piping. Three sinks are supported:
+
+| Sink | Effect |
+|------|--------|
+| `stdout` | Default; write to stdout only |
+| `file:` | Atomically write output to `` (tmp + rename) |
+| `webhook:` | POST the output body to the URL (`application/json` or `application/x-ndjson` when `--compact`) |
+
+Unknown schemes are refused with a structured error naming the supported set. Webhook failures return non-zero and log the URL + HTTP status on stderr.
+
+## Named Profiles
+
+A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern.
+
+```
+human-goat-pp-cli profile save briefing --json
+human-goat-pp-cli --profile briefing account
+human-goat-pp-cli profile list --json
+human-goat-pp-cli profile show briefing
+human-goat-pp-cli profile delete briefing --yes
+```
+
+Explicit flags always win over profile values; profile values win over defaults. `agent-context` lists all available profiles under `available_profiles` so introspecting agents discover them at runtime.
+
+## Exit Codes
+
+| Code | Meaning |
+|------|---------|
+| 0 | Success |
+| 2 | Usage error (wrong arguments) |
+| 3 | Resource not found |
+| 4 | Authentication required |
+| 5 | API error (upstream issue) |
+| 7 | Rate limited (wait and retry) |
+| 10 | Config error |
+
+## Argument Parsing
+
+Parse `$ARGUMENTS`:
+
+1. **Empty, `help`, or `--help`** → show `human-goat-pp-cli --help` output
+2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → see Prerequisites above
+3. **Anything else** → Direct Use (execute as CLI command with `--agent`)
+
+## MCP Server Installation
+
+1. Install the MCP server:
+ ```bash
+ go install github.com/mvanhorn/printing-press-library/library/productivity/human-goat/cmd/human-goat-pp-mcp@latest
+ ```
+2. Register with Claude Code:
+ ```bash
+ claude mcp add human-goat-pp-mcp -- human-goat-pp-mcp
+ ```
+3. Verify: `claude mcp list`
+
+## Direct Use
+
+1. Check if installed: `which human-goat-pp-cli`
+ If not found, offer to install (see Prerequisites at the top of this skill).
+2. Match the user query to the best command from the Unique Capabilities and Command Reference above.
+3. Execute with the `--agent` flag:
+ ```bash
+ human-goat-pp-cli [subcommand] [args] --agent
+ ```
+4. If ambiguous, drill into subcommand help: `human-goat-pp-cli --help`.
diff --git a/library/productivity/human-goat/apify-actor-audit-report.json b/library/productivity/human-goat/apify-actor-audit-report.json
new file mode 100644
index 0000000000..419f2cb6b7
--- /dev/null
+++ b/library/productivity/human-goat/apify-actor-audit-report.json
@@ -0,0 +1,9 @@
+{
+ "dir": "\u003ccli-dir\u003e/human-goat-pp-cli",
+ "research_dir": "\u003ccli-dir\u003e/20260703-152050-dff84056",
+ "verdict": "pass",
+ "actors": null,
+ "issues": [
+ "no Apify actor references found, skipping"
+ ]
+}
\ No newline at end of file
diff --git a/library/productivity/human-goat/cmd/human-goat-pp-cli/main.go b/library/productivity/human-goat/cmd/human-goat-pp-cli/main.go
new file mode 100644
index 0000000000..66bea7e38a
--- /dev/null
+++ b/library/productivity/human-goat/cmd/human-goat-pp-cli/main.go
@@ -0,0 +1,16 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package main
+
+import (
+ "os"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cli"
+)
+
+func main() {
+ if err := cli.Execute(); err != nil {
+ os.Exit(cli.ExitCode(err))
+ }
+}
diff --git a/library/productivity/human-goat/cmd/human-goat-pp-mcp/main.go b/library/productivity/human-goat/cmd/human-goat-pp-mcp/main.go
new file mode 100644
index 0000000000..462113ee0c
--- /dev/null
+++ b/library/productivity/human-goat/cmd/human-goat-pp-mcp/main.go
@@ -0,0 +1,73 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/mark3labs/mcp-go/server"
+ mcptools "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/mcp"
+)
+
+// Transport selection order: --transport flag, then PP_MCP_TRANSPORT env,
+// then the first transport declared in the spec (see MCPConfig.Transport).
+// The flag surface lets one binary serve stdio locally and streamable HTTP
+// when hosted in a container or remote sandbox, matching the Anthropic
+// guidance that production agents need a remote option.
+
+const (
+ defaultHTTPAddr = ":7777"
+)
+
+// version is the printed MCP server's version, overridable at build time via ldflags.
+var version = "0.0.0-dev"
+
+func main() {
+ // Pin the learn-event surface for this process and every walker
+ // shell-out child, so usage events record surface=mcp.
+ _ = os.Setenv("HUMAN_GOAT_LEARN_SURFACE", "mcp")
+ s := server.NewMCPServer(
+ "Human-Goat",
+ version,
+ server.WithToolCapabilities(false),
+ )
+
+ mcptools.RegisterTools(s)
+
+ transport := flag.String("transport", defaultTransport(), "MCP transport: stdio | http")
+ addr := flag.String("addr", defaultHTTPAddr, "bind address for http transport (host:port or :port)")
+ flag.Parse()
+
+ switch strings.ToLower(*transport) {
+ case "stdio":
+ if err := server.ServeStdio(s); err != nil {
+ fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+ os.Exit(1)
+ }
+ case "http":
+ httpSrv := server.NewStreamableHTTPServer(s)
+ fmt.Fprintf(os.Stderr, "human-goat-pp-mcp serving MCP over streamable HTTP at %s\n", *addr)
+ if err := httpSrv.Start(*addr); err != nil {
+ fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+ os.Exit(1)
+ }
+ default:
+ fmt.Fprintf(os.Stderr, "unknown --transport %q (supported: stdio, http)\n", *transport)
+ os.Exit(2)
+ }
+}
+
+// defaultTransport reads PP_MCP_TRANSPORT env when set, otherwise falls back
+// to "stdio" so running the binary with no args keeps today's behavior.
+// Container-hosted agents can pin the transport via env without a flag, which
+// matches how hosted-agent process supervisors typically pass configuration.
+func defaultTransport() string {
+ if t := os.Getenv("PP_MCP_TRANSPORT"); t != "" {
+ return t
+ }
+ return "stdio"
+}
diff --git a/library/productivity/human-goat/go.mod b/library/productivity/human-goat/go.mod
new file mode 100644
index 0000000000..cb0ec07c3e
--- /dev/null
+++ b/library/productivity/human-goat/go.mod
@@ -0,0 +1,29 @@
+module github.com/mvanhorn/printing-press-library/library/productivity/human-goat
+
+go 1.26.4
+
+require (
+ github.com/gorilla/websocket v1.5.3
+ github.com/mark3labs/mcp-go v0.47.0
+ github.com/pelletier/go-toml/v2 v2.2.4
+ github.com/spf13/cobra v1.9.1
+ github.com/spf13/pflag v1.0.6
+ modernc.org/sqlite v1.37.0
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/jsonschema-go v0.4.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/spf13/cast v1.7.1 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ modernc.org/libc v1.62.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.9.1 // indirect
+)
diff --git a/library/productivity/human-goat/go.sum b/library/productivity/human-goat/go.sum
new file mode 100644
index 0000000000..324a112a12
--- /dev/null
+++ b/library/productivity/human-goat/go.sum
@@ -0,0 +1,86 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mark3labs/mcp-go v0.47.0 h1:h44yeM3DduDyQgzImYWu4pt6VRkqP/0p/95AGhWngnA=
+github.com/mark3labs/mcp-go v0.47.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
+golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
+golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
+golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
+golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
+modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
+modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
+modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
+modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
+modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/library/productivity/human-goat/internal/cache/cache.go b/library/productivity/human-goat/internal/cache/cache.go
new file mode 100644
index 0000000000..8170084a3b
--- /dev/null
+++ b/library/productivity/human-goat/internal/cache/cache.go
@@ -0,0 +1,59 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package cache provides a file-based response cache with optional embedded database backend.
+// The default implementation stores JSON responses as flat files in the CLI cache directory with a TTL.
+// For higher-throughput or concurrent-write scenarios, replace the file backend with an
+// embedded database such as bolt (go.etcd.io/bbolt), badger (github.com/dgraph-io/badger),
+// or sqlite (modernc.org/sqlite).
+package cache
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// Store is a key-value cache backed by the filesystem.
+type Store struct {
+ Dir string
+ TTL time.Duration
+}
+
+// New creates a file-based cache store.
+func New(dir string, ttl time.Duration) *Store {
+ return &Store{Dir: dir, TTL: ttl}
+}
+
+// Get retrieves a cached value. Returns nil if not found or expired.
+func (s *Store) Get(key string) (json.RawMessage, bool) {
+ path := s.path(key)
+ info, err := os.Stat(path)
+ if err != nil || time.Since(info.ModTime()) > s.TTL {
+ return nil, false
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, false
+ }
+ return json.RawMessage(data), true
+}
+
+// Set stores a value in the cache.
+func (s *Store) Set(key string, value json.RawMessage) {
+ _ = os.MkdirAll(s.Dir, 0o700)
+ _ = os.WriteFile(s.path(key), []byte(value), 0o600)
+}
+
+// Clear removes all cached entries.
+func (s *Store) Clear() error {
+ return os.RemoveAll(s.Dir)
+}
+
+func (s *Store) path(key string) string {
+ h := sha256.Sum256([]byte(key))
+ return filepath.Join(s.Dir, hex.EncodeToString(h[:8])+".json")
+}
diff --git a/library/productivity/human-goat/internal/cli/agent_context.go b/library/productivity/human-goat/internal/cli/agent_context.go
new file mode 100644
index 0000000000..5f02e5b1e6
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/agent_context.go
@@ -0,0 +1,218 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "os"
+ "sort"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+// agentContextSchemaVersion is bumped on any breaking change to the JSON
+// shape emitted by `agent-context`. Agents should check this before
+// parsing. Shape at v4 adds resolved config/data/state/cache directories.
+const agentContextSchemaVersion = "4"
+
+// agentContext is the structured description of this CLI consumed by AI
+// agents. Inspired by Cloudflare's /cdn-cgi/explorer/api runtime endpoint
+// (2026-04-13 Wrangler post): agents can introspect the live CLI without
+// parsing --help or reading source.
+type agentContext struct {
+ SchemaVersion string `json:"schema_version"`
+ CLI agentContextCLI `json:"cli"`
+ Auth agentContextAuth `json:"auth"`
+ Paths agentContextPaths `json:"paths"`
+ Discovery *agentContextDiscovery `json:"discovery,omitempty"`
+ Commands []agentContextCommand `json:"commands"`
+ AvailableProfiles []string `json:"available_profiles"`
+ FeedbackEndpointConfigured bool `json:"feedback_endpoint_configured"`
+ // LearnProtocol carries the recall-first protocol from the single
+ // shared source (internal/learn.RecallFirstProtocol) also consumed by
+ // the MCP context tool, so the two agent surfaces cannot drift.
+ LearnProtocol string `json:"learn_protocol"`
+}
+
+type agentContextCLI struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Version string `json:"version"`
+}
+
+type agentContextAuth struct {
+ Mode string `json:"mode"`
+ EnvVars []agentContextAuthEnvVar `json:"env_vars"`
+}
+
+type agentContextAuthEnvVar struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Required bool `json:"required"`
+ Sensitive bool `json:"sensitive"`
+ Description string `json:"description,omitempty"`
+}
+
+type agentContextPaths struct {
+ ConfigDir string `json:"config_dir"`
+ DataDir string `json:"data_dir"`
+ StateDir string `json:"state_dir"`
+ CacheDir string `json:"cache_dir"`
+}
+
+type agentContextDiscovery struct {
+ Source string `json:"source"`
+ TargetURL string `json:"target_url,omitempty"`
+ EntryCount int `json:"entry_count,omitempty"`
+ APIEntryCount int `json:"api_entry_count,omitempty"`
+ Reachability string `json:"reachability,omitempty"`
+ Protocols []string `json:"protocols,omitempty"`
+ AuthCandidates []string `json:"auth_candidates,omitempty"`
+ Protections []string `json:"protections,omitempty"`
+ GenerationHints []string `json:"generation_hints,omitempty"`
+ Warnings []string `json:"warnings,omitempty"`
+ CandidateCommands []string `json:"candidate_commands,omitempty"`
+}
+
+type agentContextCommand struct {
+ Name string `json:"name"`
+ Use string `json:"use,omitempty"`
+ Short string `json:"short,omitempty"`
+ Annotations map[string]string `json:"annotations,omitempty"`
+ Flags []agentContextFlag `json:"flags,omitempty"`
+ Subcommands []agentContextCommand `json:"subcommands,omitempty"`
+}
+
+type agentContextFlag struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Usage string `json:"usage,omitempty"`
+ Default string `json:"default,omitempty"`
+}
+
+func newAgentContextCmd(rootCmd *cobra.Command) *cobra.Command {
+ var pretty bool
+ cmd := &cobra.Command{
+ Use: "agent-context",
+ Short: "Emit structured JSON describing this CLI for agents",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ Long: `Outputs a machine-readable description of commands, flags, and auth so
+agents can introspect this CLI at runtime without parsing --help or
+reading source. Schema is versioned via schema_version.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := buildAgentContext(rootCmd)
+ enc := json.NewEncoder(os.Stdout)
+ if pretty {
+ enc.SetIndent("", " ")
+ }
+ return enc.Encode(ctx)
+ },
+ }
+ cmd.Flags().BoolVar(&pretty, "pretty", false, "indent JSON output for human reading")
+ return cmd
+}
+
+func buildAgentContext(rootCmd *cobra.Command) agentContext {
+ envVars := []agentContextAuthEnvVar{}
+ authMode := "cookie"
+ if authMode == "" {
+ authMode = "none"
+ }
+ profiles := ListProfileNames()
+ if profiles == nil {
+ profiles = []string{}
+ }
+ return agentContext{
+ SchemaVersion: agentContextSchemaVersion,
+ CLI: agentContextCLI{
+ Name: "human-goat-pp-cli",
+ Description: "Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary.",
+ Version: rootCmd.Version,
+ },
+ Auth: agentContextAuth{
+ Mode: authMode,
+ EnvVars: envVars,
+ },
+ Paths: buildAgentContextPaths(),
+ Discovery: buildAgentDiscoveryContext(),
+ Commands: collectAgentCommands(rootCmd),
+ AvailableProfiles: profiles,
+ FeedbackEndpointConfigured: FeedbackEndpointConfigured(),
+ LearnProtocol: learn.RecallFirstProtocol,
+ }
+}
+
+func buildAgentContextPaths() agentContextPaths {
+ configDir, _ := cliutil.ConfigDir()
+ dataDir, _ := cliutil.DataDir()
+ stateDir, _ := cliutil.StateDir()
+ cacheDir, _ := cliutil.CacheDir()
+ return agentContextPaths{
+ ConfigDir: configDir,
+ DataDir: dataDir,
+ StateDir: stateDir,
+ CacheDir: cacheDir,
+ }
+}
+
+func buildAgentDiscoveryContext() *agentContextDiscovery {
+ return nil
+}
+
+// collectAgentCommands walks the cobra tree from the given command and
+// returns its direct children (skipping the agent-context command itself
+// to avoid self-reference). Each child is recursed into if it has
+// subcommands. Flags are captured via VisitAll. Output is sorted by
+// command name for stable diffs across regenerations.
+//
+// Cobra's Hidden flag suppresses listing in --help but does not gate
+// agent discovery. Raw resource parents are Hidden so --help stays
+// curated and the `api` browser populates; the agent-context surface
+// must still enumerate them and their endpoints so agents can call any
+// action a CLI user could.
+func collectAgentCommands(c *cobra.Command) []agentContextCommand {
+ children := c.Commands()
+ sort.Slice(children, func(i, j int) bool { return children[i].Name() < children[j].Name() })
+
+ out := make([]agentContextCommand, 0, len(children))
+ for _, sub := range children {
+ if sub.Name() == "agent-context" {
+ continue
+ }
+ entry := agentContextCommand{
+ Name: sub.Name(),
+ Use: sub.Use,
+ Short: sub.Short,
+ }
+ // Surface Cobra annotations (e.g., pp:endpoint, mcp:read-only) so
+ // agents and the live-dogfood classifier can detect destructive-at-auth
+ // endpoints without parsing source. Empty maps are stripped via
+ // omitempty in the struct tag.
+ if len(sub.Annotations) > 0 {
+ entry.Annotations = make(map[string]string, len(sub.Annotations))
+ for k, v := range sub.Annotations {
+ entry.Annotations[k] = v
+ }
+ }
+ sub.Flags().VisitAll(func(f *pflag.Flag) {
+ entry.Flags = append(entry.Flags, agentContextFlag{
+ Name: f.Name,
+ Type: f.Value.Type(),
+ Usage: f.Usage,
+ Default: f.DefValue,
+ })
+ })
+ sort.Slice(entry.Flags, func(i, j int) bool {
+ return entry.Flags[i].Name < entry.Flags[j].Name
+ })
+ if len(sub.Commands()) > 0 {
+ entry.Subcommands = collectAgentCommands(sub)
+ }
+ out = append(out, entry)
+ }
+ return out
+}
diff --git a/library/productivity/human-goat/internal/cli/api_discovery.go b/library/productivity/human-goat/internal/cli/api_discovery.go
new file mode 100644
index 0000000000..7fdc617277
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/api_discovery.go
@@ -0,0 +1,109 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+func newAPICmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "api [interface]",
+ Short: "Browse all API endpoints by interface name",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ Long: `Browse and call any API endpoint using the raw interface names.
+
+The friendly top-level commands cover the most common operations.
+This command provides access to ALL endpoints for power users and
+agents that need full API coverage.
+
+Run 'api' with no arguments to list all interfaces.
+Run 'api ' to see that interface's methods.`,
+ Example: ` # List all available interfaces
+ human-goat-pp-cli api
+
+ # Show methods for a specific interface
+ human-goat-pp-cli api `,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ root := cmd.Root()
+
+ if len(args) > 0 {
+ target := strings.ToLower(args[0])
+ for _, child := range root.Commands() {
+ if child.Hidden && strings.ToLower(child.Name()) == target {
+ methods := child.Commands()
+ // JSON envelope: {interface, short, methods: [{name, short}, ...]}.
+ if flags.asJSON {
+ methodList := make([]map[string]any, 0, len(methods))
+ for _, method := range methods {
+ methodList = append(methodList, map[string]any{
+ "name": method.Name(),
+ "short": method.Short,
+ })
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "interface": child.Name(),
+ "short": child.Short,
+ "methods": methodList,
+ }, flags)
+ }
+ if len(methods) == 0 {
+ return child.Help()
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "%s — %s\n\nMethods:\n", child.Name(), child.Short)
+ for _, method := range methods {
+ fmt.Fprintf(cmd.OutOrStdout(), " %-50s %s\n", child.Name()+" "+method.Name(), method.Short)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "\nUse '%s-pp-cli %s --help' for details.\n", "human-goat", child.Name())
+ return nil
+ }
+ }
+ return fmt.Errorf("interface %q not found. Run '%s-pp-cli api' to list all interfaces", args[0], "human-goat")
+ }
+
+ // Pre-formatting human strings ahead of time would block the JSON
+ // path from emitting clean field values; build the typed slice and
+ // derive human format on print.
+ type ifaceEntry struct {
+ Name string `json:"name"`
+ Short string `json:"short"`
+ }
+ var ifaces []ifaceEntry
+ for _, child := range root.Commands() {
+ if child.Hidden {
+ ifaces = append(ifaces, ifaceEntry{Name: child.Name(), Short: child.Short})
+ }
+ }
+ sort.Slice(ifaces, func(i, j int) bool { return ifaces[i].Name < ifaces[j].Name })
+
+ // JSON envelope: {interfaces: [...], note?: "..."}.
+ if flags.asJSON {
+ out := map[string]any{"interfaces": ifaces}
+ if len(ifaces) == 0 {
+ out["interfaces"] = []ifaceEntry{}
+ out["note"] = "No hidden API interfaces found."
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+
+ if len(ifaces) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No hidden API interfaces found.")
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Available API interfaces (%d):\n\n", len(ifaces))
+ for _, e := range ifaces {
+ fmt.Fprintf(cmd.OutOrStdout(), " %-45s %s\n", e.Name, e.Short)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "\nUse '%s-pp-cli api ' to see methods.\n", "human-goat")
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/auth.go b/library/productivity/human-goat/internal/cli/auth.go
new file mode 100644
index 0000000000..4306805c73
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/auth.go
@@ -0,0 +1,1261 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "github.com/gorilla/websocket"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/config"
+ "github.com/spf13/cobra"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+)
+
+func newAuthCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "auth",
+ Short: "Manage authentication for Human-Goat",
+ RunE: parentNoSubcommandRunE(flags),
+ }
+
+ cmd.AddCommand(newAuthLoginCmd(flags))
+ cmd.AddCommand(newAuthStatusCmd(flags))
+ cmd.AddCommand(newAuthSetTokenCmd(flags))
+ cmd.AddCommand(newAuthLogoutCmd(flags))
+
+ return cmd
+}
+
+// chromeProfile holds info about a discovered Chrome profile.
+type chromeProfile struct {
+ Dir string // directory name (e.g. "Default", "Profile 1")
+ DisplayName string // human-readable name from Preferences
+ CookieCount int // number of cookies matching the target domain
+ RequiredCookieCount int // number of required credential cookies present
+ MissingCookies []string // required credential cookies not present
+}
+
+func requiredAuthCookies() []string {
+ raw := []string{"session", "XSRF-TOKEN"}
+ out := raw[:0]
+ for _, name := range raw {
+ name = strings.TrimSpace(name)
+ if name != "" {
+ out = append(out, name)
+ }
+ }
+ return out
+}
+
+func newAuthLoginCmd(flags *rootFlags) *cobra.Command {
+ var browserFlag bool
+ var profileFlag string
+ var cookiesFile string
+
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Authenticate with the API",
+ Long: `Authenticate using your browser session.
+
+Use --chrome to read cookies from Chrome for .taskrabbit.com.
+Use --browser as an alias for --chrome.
+Use --cookies-file to import Playwright storage-state JSON or a raw Cookie header file.
+Requires a cookie extraction tool (pycookiecheat, cookies, or cookie-scoop-cli).
+
+If you have multiple Chrome profiles, pycookiecheat and cookie-scoop-cli can
+auto-detect which profile is logged in. Use --profile to select a specific
+profile by name when the installed backend supports it.`,
+ Example: ` human-goat-pp-cli auth login --chrome
+ human-goat-pp-cli auth login --browser
+ human-goat-pp-cli auth login --cookies-file storage-state.json
+ human-goat-pp-cli auth login --chrome --profile "Work"`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ w := cmd.OutOrStdout()
+ domain := ".taskrabbit.com"
+ if !browserFlag && cookiesFile == "" {
+ fmt.Fprintln(w, "Use --chrome, --browser, or --cookies-file to authenticate from your browser session:")
+ fmt.Fprintf(cmd.OutOrStdout(), " human-goat-pp-cli auth login --chrome\n")
+ fmt.Fprintf(cmd.OutOrStdout(), " human-goat-pp-cli auth login --browser\n")
+ fmt.Fprintf(cmd.OutOrStdout(), " human-goat-pp-cli auth login --cookies-file storage-state.json\n")
+ return nil
+ }
+ if browserFlag && cookiesFile != "" {
+ return authErr(fmt.Errorf("choose either --chrome/--browser or --cookies-file, not both"))
+ }
+
+ // Step 0: Try press-auth, the dedicated cookie capture companion.
+ // press-auth spawns its own controlled Chrome window so users do
+ // not need to relaunch their daily Chrome with
+ // --remote-debugging-port. If press-auth is not installed, fall
+ // through silently to the legacy extraction chain. If press-auth
+ // is installed but errored (e.g. ExitNotCaptured because the user
+ // has not logged in yet), surface its message and still fall
+ // through — the legacy chain may succeed if persistent cookies
+ // are already on disk.
+ var cookies string
+ fromPressAuth := false
+ fromCookiesFile := false
+ if cookiesFile != "" {
+ imported, err := loadCookiesFromFile(cookiesFile, domain)
+ if err != nil {
+ return authErr(err)
+ }
+ cookies = imported.Header
+ fromCookiesFile = true
+ fmt.Fprintf(w, "Loaded cookies from %s for %s.\n", cookiesFile, domain)
+ }
+ if !fromCookiesFile {
+ if pressAuthPath, err := exec.LookPath("press-auth"); err == nil {
+ paCookies, paErr := tryPressAuth(pressAuthPath, domain)
+ if paErr == nil && paCookies != "" {
+ fmt.Fprintf(w, "Loaded cookies via press-auth for %s.\n", domain)
+ cookies = paCookies
+ fromPressAuth = true
+ } else if paErr != nil {
+ fmt.Fprintf(w, "press-auth returned an error: %v\n", paErr)
+ fmt.Fprintln(w, "Falling back to other extraction methods.")
+ fmt.Fprintln(w, "If you haven't logged in yet, run:")
+ fmt.Fprintf(w, "\n press-auth login %s --login-url \n\n", domain)
+ }
+ }
+ }
+
+ if !fromPressAuth && !fromCookiesFile {
+ // Step 1: Detect cookie extraction tool
+ tool, err := detectCookieTool()
+ if err != nil {
+ fmt.Fprintln(w, red("No cookie extraction tool found."))
+ fmt.Fprintln(w, "")
+ if runtime.GOOS == "windows" {
+ fmt.Fprintln(w, "pycookiecheat does not support Windows. Read cookies from a live Chrome session instead:")
+ fmt.Fprintf(w, "\n human-goat-pp-cli auth login --browser\n\n")
+ fmt.Fprintln(w, "Or install a cross-platform reader:")
+ fmt.Fprintln(w, " cargo install cookie-scoop-cli # Rust")
+ } else {
+ fmt.Fprintln(w, "Install one of:")
+ fmt.Fprintln(w, " pip install pycookiecheat # Python (recommended)")
+ fmt.Fprintln(w, " brew install barnardb/cookies/cookies # Homebrew")
+ fmt.Fprintln(w, " cargo install cookie-scoop-cli # Rust")
+ }
+ return authErr(fmt.Errorf("no cookie tool found"))
+ }
+
+ if profileFlag != "" && !cookieToolSupportsProfiles(tool.name) {
+ return authErr(fmt.Errorf("%s does not support --profile; install pycookiecheat or cookie-scoop-cli", tool.name))
+ }
+
+ // Step 2: Resolve which Chrome profile to use when the backend can honor it
+ profileDir := ""
+ if cookieToolSupportsProfiles(tool.name) {
+ profileDir, err = resolveChromeProfile(w, cmd.InOrStdin(), domain, profileFlag, requiredAuthCookies())
+ if err != nil {
+ loginURL := "https://" + strings.TrimPrefix(domain, ".")
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, red("No Chrome profile has cookies for "+domain+"."))
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Log in to your account:")
+ fmt.Fprintf(w, "\n %s\n\n", loginURL)
+ fmt.Fprintln(w, "Then run this command again:")
+ fmt.Fprintf(w, "\n human-goat-pp-cli auth login --chrome\n")
+ return authErr(fmt.Errorf("not logged in to %s", domain))
+ }
+ }
+
+ if profileDir != "" {
+ fmt.Fprintf(w, "Reading cookies from Chrome profile %q for %s...\n", profileDir, domain)
+ } else {
+ fmt.Fprintf(w, "Reading cookies for %s...\n", domain)
+ }
+
+ // Step 3: Extract cookies from the resolved profile
+ cookies, err = extractCookies(tool, domain, profileDir)
+ if err != nil {
+ return authErr(fmt.Errorf("extracting cookies: %w", err))
+ }
+
+ if cookies == "" {
+ return authErr(fmt.Errorf("cookie tool returned no cookies for %s", domain))
+ }
+ } // end if !fromPressAuth
+ // Unfiltered, the persisted blob carries every cookie the target
+ // domain has set (CSRF, WAF, anti-bot fingerprints, etc.) into the
+ // Cookie header on every request, which routinely trips upstream
+ // WAFs and shadows the real credential.
+ cookieMap := parseCookieString(cookies)
+ requiredCookies := []string{"session", "XSRF-TOKEN"}
+ kept := make([]string, 0, len(requiredCookies))
+ for _, name := range requiredCookies {
+ v, ok := cookieMap[name]
+ if !ok {
+ loginURL := "https://" + strings.TrimPrefix(domain, ".")
+ fmt.Fprintf(w, "\n%s Cookie %q not found for %s.\n", red("ERROR"), name, domain)
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Log in to your account:")
+ fmt.Fprintf(w, "\n %s\n\n", loginURL)
+ fmt.Fprintln(w, "Then run this command again:")
+ fmt.Fprintf(w, "\n human-goat-pp-cli auth login --chrome\n")
+ return authErr(fmt.Errorf("cookie %q not found for %s", name, domain))
+ }
+ kept = append(kept, name+"="+v)
+ }
+ cookies = strings.Join(kept, "; ")
+
+ // Step 5: Save to config
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ if err := cfg.SaveTokens("", "", cookies, "", time.Time{}); err != nil {
+ return configErr(fmt.Errorf("saving cookies: %w", err))
+ }
+ // Persist the required cookies so client.LoadCookieJar() carries
+ // them on subsequent invocations; the SaveTokens blob above feeds
+ // the Authorization header path but is invisible to the HTTP
+ // client's cookie jar.
+ jarCookies := make(map[string]string, len(requiredCookies))
+ for _, name := range requiredCookies {
+ if v, ok := cookieMap[name]; ok {
+ jarCookies[name] = v
+ }
+ }
+ if err := client.WriteCookieJarFromMap(domain, jarCookies); err != nil {
+ fmt.Fprintf(w, "warning: persisting cookie jar: %v (credentials still saved)\n", err)
+ }
+
+ count := len(strings.Split(cookies, ";"))
+ fmt.Fprintf(w, "%s Found %d cookies for %s\n", green("OK"), count, domain)
+ fmt.Fprintf(w, "Session saved to %s\n", credentialSavePath(cfg))
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVar(&browserFlag, "chrome", false, "Read cookies from Chrome")
+ cmd.Flags().BoolVar(&browserFlag, "browser", false, "Alias for --chrome")
+ cmd.Flags().StringVar(&profileFlag, "profile", "", "Chrome profile name (e.g. \"Work\", \"Personal\")")
+ cmd.Flags().StringVar(&cookiesFile, "cookies-file", "", "Import cookies from a Playwright storage-state JSON file or raw Cookie header file")
+ return cmd
+}
+func cookieToolSupportsProfiles(tool string) bool {
+ switch tool {
+ case "pycookiecheat", "pycookiecheat-cli", "cookie-scoop":
+ return true
+ default:
+ return false
+ }
+}
+
+func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Show authentication status",
+ Example: " human-goat-pp-cli auth status",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ w := cmd.OutOrStdout()
+ header := cfg.AuthHeader()
+ if header == "" {
+ fmt.Fprintln(w, red("Not authenticated"))
+ fmt.Fprintln(w, "")
+ fmt.Fprintln(w, "Log in from your browser session:")
+ fmt.Fprintf(w, " human-goat-pp-cli auth login --chrome\n")
+ return authErr(fmt.Errorf("no credentials configured"))
+ }
+
+ fmt.Fprintln(w, green("Authenticated"))
+ fmt.Fprintf(w, " Source: %s\n", cfg.AuthSource)
+ fmt.Fprintf(w, " Domain: .taskrabbit.com\n")
+ fmt.Fprintf(w, " Config: %s\n", cfg.Path)
+ return nil
+ },
+ }
+}
+
+// newAuthSetTokenCmd is the escape hatch for sites whose access token can't
+// be reached by the cookie-jar extractor in `auth login --chrome` — most
+// commonly Auth0 SPA SDK v2+ deployments using the default in-memory cache,
+// where the JWT lives in JS heap and never touches cookies or localStorage.
+// Users (or agents) grab the bearer from DevTools → Network → Authorization
+// and paste it here instead of hand-editing config.toml.
+//
+// The cliutil.LooksLikeJWT length floor is the cookie-vs-JWT-shape gate from
+// retro #1598 WU-2: short tracking cookies like Cloudflare's `__cf_bm`
+// (~31 chars) happen to have three base64url segments and would otherwise
+// silently land as the access token.
+func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "set-token ",
+ Short: "Save a bearer JWT pasted from DevTools (escape hatch for in-memory tokens)",
+ Example: " human-goat-pp-cli auth set-token eyJhbGciOi...",
+ Long: "Save a bearer JWT pasted from DevTools (escape hatch for in-memory tokens).\n\n" +
+ "Use this when `auth login --chrome` can't reach the access token — most\n" +
+ "commonly Auth0 SPA SDK deployments that keep the JWT in JS heap memory.\n" +
+ "Open DevTools → Network → any authenticated request → copy the\n" +
+ "`Authorization: Bearer ...` value and paste the JWT portion here.\n\n" +
+ "The token is validated for JWT shape (three base64url segments, length\n" +
+ "floor) so short tracking cookies don't get saved as credentials.",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ token := strings.TrimSpace(args[0])
+ token = strings.TrimPrefix(token, "Bearer ")
+ if !cliutil.LooksLikeJWT(token) {
+ return authErr(fmt.Errorf("argument is not JWT-shaped (three base64url segments, ≥150 chars total). " +
+ "This is the cookie-vs-JWT-shape gate — short tracking cookies like Cloudflare's __cf_bm " +
+ "share the segment shape but aren't credentials. Paste the `Authorization: Bearer ...` value " +
+ "from DevTools → Network, not a cookie jar"))
+ }
+
+ // Clear any legacy auth_header so AuthHeader() falls through to
+ // the newly-saved bearer. Mirrors auth_simple's set-token.
+ cfg.AuthHeaderVal = ""
+ // Pass a zero expiry. We can't decode the JWT to learn its real
+ // lifetime, and preserving a stale cfg.TokenExpiry from a prior
+ // session (the exact case escape-hatch users reach this from)
+ // would later mislead any expiry-aware status check into treating
+ // this fresh token as already expired. Actual expiry surfaces via
+ // 401 on the next API call, same as the rest of the browser-auth
+ // flow. Matches ClearTokens / SaveBearerToken's zero-on-write
+ // invariant in config.go.tmpl.
+ if err := cfg.SaveTokens("", "", token, "", time.Time{}); err != nil {
+ return configErr(fmt.Errorf("saving token: %w", err))
+ }
+
+ savePath := credentialSavePath(cfg)
+ // JSON envelope: {saved, config_path, credentials_path}.
+ if flags.asJSON {
+ out := map[string]any{
+ "saved": true,
+ "config_path": cfg.Path,
+ }
+ if !cfg.AgentcookieManagedByExternalStore() {
+ out["credentials_path"] = savePath
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", savePath)
+ return nil
+ },
+ }
+}
+
+func credentialSavePath(cfg *config.Config) string {
+ if cfg != nil && cfg.AgentcookieManagedByExternalStore() {
+ return cfg.Path
+ }
+ if path, err := cliutil.CredentialsFilePath(); err == nil {
+ return path
+ }
+ if cfg != nil {
+ return cfg.Path
+ }
+ return ""
+}
+
+func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "logout",
+ Short: "Clear stored credentials",
+ Example: " human-goat-pp-cli auth logout",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ return configErr(err)
+ }
+
+ if err := cfg.ClearTokens(); err != nil {
+ return configErr(fmt.Errorf("clearing tokens: %w", err))
+ }
+ fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
+ return nil
+ },
+ }
+}
+
+// --- Chrome profile discovery ---
+// chromeDataDir returns the Chrome user data directory for the current OS.
+func chromeDataDir() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+
+ switch runtime.GOOS {
+ case "darwin":
+ return filepath.Join(home, "Library", "Application Support", "Google", "Chrome"), nil
+ case "linux":
+ return filepath.Join(home, ".config", "google-chrome"), nil
+ case "windows":
+ localAppData := os.Getenv("LOCALAPPDATA")
+ if localAppData == "" {
+ localAppData = filepath.Join(home, "AppData", "Local")
+ }
+ return filepath.Join(localAppData, "Google", "Chrome", "User Data"), nil
+ default:
+ return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS)
+ }
+}
+
+// discoverChromeProfiles finds Chrome profiles and counts cookies matching the domain.
+// When requiredCookies is non-empty, profiles with those credential cookies rank
+// ahead of profiles that only have guest, analytics, or challenge cookies.
+func discoverChromeProfiles(domain string, requiredCookies []string) ([]chromeProfile, error) {
+ dataDir, err := chromeDataDir()
+ if err != nil {
+ return nil, err
+ }
+ if _, err := exec.LookPath("sqlite3"); err != nil {
+ return nil, fmt.Errorf("sqlite3 not found")
+ }
+
+ entries, err := os.ReadDir(dataDir)
+ if err != nil {
+ return nil, fmt.Errorf("cannot read Chrome data directory: %w", err)
+ }
+
+ // Match domain for SQLite query — host_key uses leading dot (e.g. ".notion.so")
+ domainPattern := "%" + strings.TrimPrefix(domain, ".") + "%"
+
+ var profiles []chromeProfile
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if name != "Default" && !strings.HasPrefix(name, "Profile ") {
+ continue
+ }
+
+ profilePath := filepath.Join(dataDir, name)
+ cookiesDB := filepath.Join(profilePath, "Cookies")
+ if _, err := os.Stat(cookiesDB); err != nil {
+ continue
+ }
+
+ displayName := readProfileDisplayName(filepath.Join(profilePath, "Preferences"))
+ if displayName == "" {
+ displayName = name
+ }
+
+ count, requiredCount, missing := inspectCookiesForDomain(cookiesDB, domainPattern, requiredCookies)
+
+ profiles = append(profiles, chromeProfile{
+ Dir: name,
+ DisplayName: displayName,
+ CookieCount: count,
+ RequiredCookieCount: requiredCount,
+ MissingCookies: missing,
+ })
+ }
+
+ // Sort: profiles with credential cookies first, then by total cookie count,
+ // then by directory name for deterministic prompts.
+ sort.Slice(profiles, func(i, j int) bool {
+ if profiles[i].RequiredCookieCount != profiles[j].RequiredCookieCount {
+ return profiles[i].RequiredCookieCount > profiles[j].RequiredCookieCount
+ }
+ if profiles[i].CookieCount != profiles[j].CookieCount {
+ return profiles[i].CookieCount > profiles[j].CookieCount
+ }
+ return profiles[i].Dir < profiles[j].Dir
+ })
+
+ return profiles, nil
+}
+
+// readProfileDisplayName extracts the human-readable name from Chrome's Preferences JSON.
+func readProfileDisplayName(prefsPath string) string {
+ data, err := os.ReadFile(prefsPath)
+ if err != nil {
+ return ""
+ }
+ var prefs struct {
+ Profile struct {
+ Name string `json:"name"`
+ } `json:"profile"`
+ }
+ if err := json.Unmarshal(data, &prefs); err != nil {
+ return ""
+ }
+ return prefs.Profile.Name
+}
+
+// inspectCookiesForDomain copies the Cookies DB (plus WAL/SHM) to temp and counts matching rows.
+// Uses sqlite3 when available; host_key is plaintext so no decryption is needed.
+func inspectCookiesForDomain(cookiesDB, domainPattern string, requiredCookies []string) (count int, requiredCount int, missing []string) {
+ tmpFile, err := os.CreateTemp("", "cookies-probe-*.db")
+ if err != nil {
+ return 0, 0, append([]string{}, requiredCookies...)
+ }
+ tmpPath := tmpFile.Name()
+ tmpFile.Close()
+ defer os.Remove(tmpPath)
+ defer os.Remove(tmpPath + "-wal")
+ defer os.Remove(tmpPath + "-shm")
+
+ // Copy the database file plus WAL/SHM to avoid Chrome's WAL lock
+ // and to include uncommitted cookie writes that are still in the WAL.
+ if err := copyFileIfExists(cookiesDB, tmpPath); err != nil {
+ return 0, 0, append([]string{}, requiredCookies...)
+ }
+ _ = copyFileIfExists(cookiesDB+"-wal", tmpPath+"-wal")
+ _ = copyFileIfExists(cookiesDB+"-shm", tmpPath+"-shm")
+
+ query := fmt.Sprintf("SELECT COUNT(*) FROM cookies WHERE host_key LIKE '%s'", sqlQuoteLiteral(domainPattern))
+ out, err := exec.Command("sqlite3", tmpPath, query).Output()
+ if err != nil {
+ return 0, 0, append([]string{}, requiredCookies...)
+ }
+
+ fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &count)
+
+ if len(requiredCookies) == 0 || count == 0 {
+ return count, 0, append([]string{}, requiredCookies...)
+ }
+
+ quotedNames := make([]string, 0, len(requiredCookies))
+ for _, name := range requiredCookies {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ quotedNames = append(quotedNames, "'"+sqlQuoteLiteral(name)+"'")
+ }
+ if len(quotedNames) == 0 {
+ return count, 0, nil
+ }
+
+ query = fmt.Sprintf(
+ "SELECT DISTINCT name FROM cookies WHERE host_key LIKE '%s' AND name IN (%s)",
+ sqlQuoteLiteral(domainPattern),
+ strings.Join(quotedNames, ","),
+ )
+ out, err = exec.Command("sqlite3", tmpPath, query).Output()
+ if err != nil {
+ return count, 0, append([]string{}, requiredCookies...)
+ }
+
+ present := map[string]bool{}
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ name := strings.TrimSpace(line)
+ if name != "" && !present[name] {
+ present[name] = true
+ requiredCount++
+ }
+ }
+ for _, name := range requiredCookies {
+ name = strings.TrimSpace(name)
+ if name != "" && !present[name] {
+ missing = append(missing, name)
+ }
+ }
+ return count, requiredCount, missing
+}
+
+func sqlQuoteLiteral(s string) string {
+ return strings.ReplaceAll(s, "'", "''")
+}
+
+// copyFileIfExists copies src to dst. Returns nil if src does not exist.
+func copyFileIfExists(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ defer in.Close()
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, in)
+ return err
+}
+
+// resolveChromeProfile determines which Chrome profile to read cookies from.
+// Priority: --profile flag > auto-detect (single match) > interactive prompt.
+func resolveChromeProfile(w io.Writer, r io.Reader, domain, profileFlag string, requiredCookies []string) (string, error) {
+ if profileFlag != "" {
+ return resolveProfileByName(profileFlag)
+ }
+
+ profiles, err := discoverChromeProfiles(domain, requiredCookies)
+ if err != nil {
+ // Profile probing is optional; cookie extraction can still succeed without it.
+ fmt.Fprintf(w, "Could not inspect Chrome profiles (%v); continuing without auto-detection.\n", err)
+ return "", nil
+ }
+
+ // Prefer profiles that have all required credential cookies. A profile with
+ // only guest/challenge cookies can make the user look "not logged in" even
+ // though another Chrome profile has the active session.
+ if len(requiredCookies) > 0 {
+ var withRequiredCookies []chromeProfile
+ for _, p := range profiles {
+ if p.CookieCount > 0 && p.RequiredCookieCount == len(requiredCookies) {
+ withRequiredCookies = append(withRequiredCookies, p)
+ }
+ }
+ if len(withRequiredCookies) > 0 {
+ return chooseChromeProfile(w, r, domain, withRequiredCookies, true)
+ }
+ printMissingCookieHint(w, profiles, requiredCookies)
+ }
+
+ // Fall back to profiles that have any cookie for this domain. This preserves
+ // behavior for specs that do not enumerate cookie names.
+ var withCookies []chromeProfile
+ for _, p := range profiles {
+ if p.CookieCount > 0 {
+ withCookies = append(withCookies, p)
+ }
+ }
+
+ if len(withCookies) == 0 {
+ return "", fmt.Errorf("no Chrome profile has cookies for %s", domain)
+ }
+ return chooseChromeProfile(w, r, domain, withCookies, false)
+}
+
+func printMissingCookieHint(w io.Writer, profiles []chromeProfile, requiredCookies []string) {
+ if len(requiredCookies) == 0 {
+ return
+ }
+ for _, p := range profiles {
+ if p.CookieCount == 0 || len(p.MissingCookies) == 0 {
+ continue
+ }
+ fmt.Fprintf(w, "Chrome profile %s (%s) is missing required cookies: %s\n", p.DisplayName, p.Dir, strings.Join(p.MissingCookies, ", "))
+ return
+ }
+}
+
+func chooseChromeProfile(w io.Writer, r io.Reader, domain string, profiles []chromeProfile, matchedRequired bool) (string, error) {
+ switch len(profiles) {
+ case 1:
+ if matchedRequired {
+ fmt.Fprintf(w, "Auto-detected Chrome profile: %s (%s, required cookies present)\n", profiles[0].DisplayName, profiles[0].Dir)
+ } else {
+ fmt.Fprintf(w, "Auto-detected Chrome profile: %s (%s)\n", profiles[0].DisplayName, profiles[0].Dir)
+ }
+ return profiles[0].Dir, nil
+ default:
+ // Multiple profiles have cookies.
+ // Non-interactive: pick the profile with the most cookies (already sorted).
+ // Interactive: ask the user.
+ if !isTerminal(w) {
+ // Non-interactive — auto-select the best profile (most cookies, first in sorted list)
+ selected := profiles[0]
+ if matchedRequired {
+ fmt.Fprintf(w, "Auto-selected Chrome profile: %s (%s, required cookies present, %d cookies)\n", selected.DisplayName, selected.Dir, selected.CookieCount)
+ } else {
+ fmt.Fprintf(w, "Auto-selected Chrome profile: %s (%s, %d cookies)\n", selected.DisplayName, selected.Dir, selected.CookieCount)
+ }
+ fmt.Fprintf(w, "Use --profile to select a different profile.\n")
+ return selected.Dir, nil
+ }
+
+ fmt.Fprintf(w, "Multiple Chrome profiles have cookies for %s:\n", domain)
+ for i, p := range profiles {
+ if matchedRequired {
+ fmt.Fprintf(w, " %d. %s (%s, required cookies present, %d cookies)\n", i+1, p.DisplayName, p.Dir, p.CookieCount)
+ } else {
+ fmt.Fprintf(w, " %d. %s (%s, %d cookies)\n", i+1, p.DisplayName, p.Dir, p.CookieCount)
+ }
+ }
+ fmt.Fprintf(w, "Which profile? [1]: ")
+
+ scanner := bufio.NewScanner(r)
+ choice := 1
+ if scanner.Scan() {
+ text := strings.TrimSpace(scanner.Text())
+ if text != "" {
+ fmt.Sscanf(text, "%d", &choice)
+ }
+ }
+ if choice < 1 || choice > len(profiles) {
+ choice = 1
+ }
+ selected := profiles[choice-1]
+ fmt.Fprintf(w, "Using profile: %s (%s)\n", selected.DisplayName, selected.Dir)
+ return selected.Dir, nil
+ }
+}
+
+// resolveProfileByName finds a Chrome profile directory by its display name.
+func resolveProfileByName(name string) (string, error) {
+ dataDir, err := chromeDataDir()
+ if err != nil {
+ return "", err
+ }
+
+ entries, err := os.ReadDir(dataDir)
+ if err != nil {
+ return "", err
+ }
+
+ lowerName := strings.ToLower(name)
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ dirName := entry.Name()
+ if dirName != "Default" && !strings.HasPrefix(dirName, "Profile ") {
+ continue
+ }
+ displayName := readProfileDisplayName(filepath.Join(dataDir, dirName, "Preferences"))
+ if strings.ToLower(displayName) == lowerName || strings.ToLower(dirName) == lowerName {
+ return dirName, nil
+ }
+ }
+
+ return "", fmt.Errorf("Chrome profile %q not found", name)
+}
+
+// --- Cookie extraction tools ---
+
+// resolvePythonBinary finds a usable Python 3 interpreter, returning the
+// executable name and any leading args (e.g. `py -3` on Windows). pycookiecheat
+// is the only tool the CLI shells into Python for, and Windows ships
+// `python.exe` or the `py` launcher rather than `python3`.
+func resolvePythonBinary() (string, []string, bool) {
+ candidates := []struct {
+ bin string
+ args []string
+ }{
+ {"python3", nil},
+ {"python", nil},
+ {"py", []string{"-3"}},
+ }
+ for _, c := range candidates {
+ if _, err := exec.LookPath(c.bin); err == nil {
+ return c.bin, c.args, true
+ }
+ }
+ return "", nil, false
+}
+
+// cookieTool bundles the detected tool name with any auxiliary invocation
+// data the extraction call needs. For pycookiecheat we carry the Python
+// binary and its leading args so detection and extraction cannot disagree
+// if PATH mutates between calls.
+type cookieTool struct {
+ name string
+ pyBin string
+ pyArgs []string
+}
+
+// tryPressAuth shells out to `press-auth cookies ` and returns the
+// resulting Cookie header value. Stderr from press-auth (if any) is surfaced
+// as the error message so callers can include the recovery hint.
+func tryPressAuth(pressAuthPath, domain string) (string, error) {
+ cmd := exec.Command(pressAuthPath, "cookies", domain)
+ var out, errBuf bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &errBuf
+ if err := cmd.Run(); err != nil {
+ msg := strings.TrimSpace(errBuf.String())
+ if msg != "" {
+ return "", fmt.Errorf("%s", msg)
+ }
+ return "", err
+ }
+ return strings.TrimSpace(out.String()), nil
+}
+
+// detectCookieTool checks for available cookie extraction tools in preference order.
+// pycookiecheat is skipped on Windows because upstream raises
+// `OSError("This script only works on MacOS or Linux.")` regardless of whether
+// the import probe succeeds.
+func detectCookieTool() (cookieTool, error) {
+ if runtime.GOOS != "windows" {
+ if bin, args, ok := resolvePythonBinary(); ok {
+ probeArgs := append(append([]string{}, args...), "-c", "import pycookiecheat")
+ if err := exec.Command(bin, probeArgs...).Run(); err == nil {
+ return cookieTool{name: "pycookiecheat", pyBin: bin, pyArgs: args}, nil
+ }
+ }
+ if path, err := exec.LookPath("pycookiecheat"); err == nil {
+ if err := exec.Command(path, "--help").Run(); err == nil {
+ return cookieTool{name: "pycookiecheat-cli", pyBin: path}, nil
+ }
+ }
+ }
+ if err := exec.Command("cookies", "--help").Run(); err == nil {
+ return cookieTool{name: "cookies"}, nil
+ }
+ if err := exec.Command("cookie-scoop", "--help").Run(); err == nil {
+ return cookieTool{name: "cookie-scoop"}, nil
+ }
+ if runtime.GOOS == "windows" {
+ return cookieTool{}, fmt.Errorf("no cookie extraction tool found; pycookiecheat does not support Windows. Use `auth login --browser` (live Chrome via CDP) or install cookie-scoop-cli")
+ }
+ return cookieTool{}, fmt.Errorf("no cookie extraction tool found; install one: pip install pycookiecheat")
+}
+
+// extractCookies shells out to the detected tool and returns a Cookie header value string.
+// profileDir selects which Chrome profile to read from (e.g. "Default", "Profile 1").
+func extractCookies(tool cookieTool, domain, profileDir string) (string, error) {
+ switch tool.name {
+ case "pycookiecheat":
+ return extractViaPycookiecheat(tool, domain, profileDir)
+ case "pycookiecheat-cli":
+ return extractViaPycookiecheatCLI(tool, domain, profileDir)
+ case "cookies":
+ return extractViaCookiesCLI(domain)
+ case "cookie-scoop":
+ return extractViaCookieScoop(domain, profileDir)
+ default:
+ return "", fmt.Errorf("unknown cookie tool: %s", tool.name)
+ }
+}
+
+func extractViaPycookiecheat(tool cookieTool, domain, profileDir string) (string, error) {
+ cleanDomain := strings.TrimPrefix(domain, ".")
+ cookiePath := ""
+ if profileDir != "" {
+ dataDir, err := chromeDataDir()
+ if err == nil {
+ cookiePath = filepath.Join(dataDir, profileDir, "Cookies")
+ }
+ }
+
+ var script string
+ if cookiePath != "" {
+ // Use forward slashes so Python doesn't interpret backslashes as escapes on Windows
+ safePath := filepath.ToSlash(cookiePath)
+ script = fmt.Sprintf(
+ `import json; from pycookiecheat import chrome_cookies; print(json.dumps(chrome_cookies("https://%s", cookie_file="%s")))`,
+ cleanDomain, safePath,
+ )
+ } else {
+ script = fmt.Sprintf(
+ `import json; from pycookiecheat import chrome_cookies; print(json.dumps(chrome_cookies("https://%s")))`,
+ cleanDomain,
+ )
+ }
+
+ var out bytes.Buffer
+ cmd := exec.Command(tool.pyBin, append(append([]string{}, tool.pyArgs...), "-c", script)...)
+ cmd.Stdout = &out
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("pycookiecheat failed: %w", err)
+ }
+
+ var cookies map[string]string
+ if err := json.Unmarshal(out.Bytes(), &cookies); err != nil {
+ return "", fmt.Errorf("parsing pycookiecheat output: %w", err)
+ }
+
+ var parts []string
+ for name, value := range cookies {
+ parts = append(parts, name+"="+value)
+ }
+ return strings.Join(parts, "; "), nil
+}
+
+func extractViaPycookiecheatCLI(tool cookieTool, domain, profileDir string) (string, error) {
+ cleanDomain := strings.TrimPrefix(domain, ".")
+ var args []string
+ if profileDir != "" {
+ dataDir, err := chromeDataDir()
+ if err != nil {
+ return "", err
+ }
+ args = append(args, "-c", filepath.Join(dataDir, profileDir, "Cookies"))
+ }
+ args = append(args, "https://"+cleanDomain)
+
+ var out bytes.Buffer
+ cmd := exec.Command(tool.pyBin, args...)
+ cmd.Stdout = &out
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("pycookiecheat cli failed: %w", err)
+ }
+
+ var cookies map[string]string
+ if err := json.Unmarshal(out.Bytes(), &cookies); err != nil {
+ return "", fmt.Errorf("parsing pycookiecheat output: %w", err)
+ }
+
+ var parts []string
+ for name, value := range cookies {
+ parts = append(parts, name+"="+value)
+ }
+ return strings.Join(parts, "; "), nil
+}
+
+func extractViaCookiesCLI(domain string) (string, error) {
+ // barnardb/cookies doesn't support profile selection — reads Default
+ var out bytes.Buffer
+ cmd := exec.Command("cookies", "https://"+strings.TrimPrefix(domain, "."))
+ cmd.Stdout = &out
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("cookies cli failed: %w", err)
+ }
+
+ result := strings.TrimSpace(out.String())
+ result = strings.TrimPrefix(result, "Cookie: ")
+ return result, nil
+}
+
+type importedCookieFile struct {
+ Header string
+ Cookies []*http.Cookie
+}
+
+func loadCookiesFromFile(path string, domain string) (importedCookieFile, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return importedCookieFile{}, fmt.Errorf("reading cookies file: %w", err)
+ }
+ var state struct {
+ Cookies []struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+ Domain string `json:"domain"`
+ Path string `json:"path"`
+ Expires float64 `json:"expires"`
+ Secure bool `json:"secure"`
+ HTTPOnly bool `json:"httpOnly"`
+ } `json:"cookies"`
+ }
+ if err := json.Unmarshal(data, &state); err == nil {
+ if len(state.Cookies) == 0 {
+ return importedCookieFile{}, fmt.Errorf("cookies file contains an empty cookies array")
+ }
+ parts := make([]string, 0, len(state.Cookies))
+ cookies := make([]*http.Cookie, 0, len(state.Cookies))
+ for _, c := range state.Cookies {
+ if !cookieDomainMatches(c.Domain, domain) {
+ continue
+ }
+ name := strings.TrimSpace(c.Name)
+ if name == "" {
+ continue
+ }
+ path := c.Path
+ if path == "" {
+ path = "/"
+ }
+ ck := &http.Cookie{
+ Name: name,
+ Value: c.Value,
+ Domain: c.Domain,
+ Path: path,
+ Secure: c.Secure,
+ HttpOnly: c.HTTPOnly,
+ }
+ if c.Expires > 0 {
+ ck.Expires = time.Unix(int64(c.Expires), 0)
+ }
+ cookies = append(cookies, ck)
+ parts = append(parts, name+"="+c.Value)
+ }
+ if len(parts) == 0 {
+ return importedCookieFile{}, fmt.Errorf("cookies file contains no cookies for %s", domain)
+ }
+ return importedCookieFile{Header: strings.Join(parts, "; "), Cookies: cookies}, nil
+ }
+ header := trimCookieHeaderPrefix(string(data))
+ if header == "" {
+ return importedCookieFile{}, fmt.Errorf("cookies file is empty")
+ }
+ return importedCookieFile{Header: header, Cookies: parseCookieHeaderCookies(header)}, nil
+}
+
+func trimCookieHeaderPrefix(s string) string {
+ s = strings.TrimSpace(s)
+ if len(s) >= len("Cookie:") && strings.EqualFold(s[:len("Cookie:")], "Cookie:") {
+ return strings.TrimSpace(s[len("Cookie:"):])
+ }
+ return s
+}
+
+func parseCookieHeaderCookies(header string) []*http.Cookie {
+ parts := strings.Split(header, ";")
+ cookies := make([]*http.Cookie, 0, len(parts))
+ for _, part := range parts {
+ name, value, ok := strings.Cut(strings.TrimSpace(part), "=")
+ if !ok || strings.TrimSpace(name) == "" {
+ continue
+ }
+ cookies = append(cookies, &http.Cookie{Name: strings.TrimSpace(name), Value: strings.TrimSpace(value), Path: "/"})
+ }
+ return cookies
+}
+
+func cookieDomainMatches(cookieDomain string, targetDomain string) bool {
+ target := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(targetDomain)), ".")
+ if target == "" {
+ return true
+ }
+ candidate := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(cookieDomain)), ".")
+ return candidate == target || strings.HasSuffix(candidate, "."+target) || (strings.Contains(candidate, ".") && strings.HasSuffix(target, "."+candidate))
+}
+
+// parseCookieString splits a "name1=value1; name2=value2" string into a map.
+func parseCookieString(cookies string) map[string]string {
+ m := make(map[string]string)
+ for _, pair := range strings.Split(cookies, "; ") {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+ idx := strings.IndexByte(pair, '=')
+ if idx < 0 {
+ continue
+ }
+ m[pair[:idx]] = pair[idx+1:]
+ }
+ return m
+}
+
+func extractViaCookieScoop(domain, profileDir string) (string, error) {
+ cleanDomain := strings.TrimPrefix(domain, ".")
+ args := []string{"--url", "https://" + cleanDomain, "--header"}
+ if profileDir != "" {
+ args = append(args, "--chrome-profile", profileDir)
+ }
+
+ var out bytes.Buffer
+ cmd := exec.Command("cookie-scoop", args...)
+ cmd.Stdout = &out
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("cookie-scoop failed: %w", err)
+ }
+
+ result := strings.TrimSpace(out.String())
+ result = strings.TrimPrefix(result, "Cookie: ")
+ return result, nil
+}
+
+// --- Live browser cookie extraction (session cookies fallback) ---
+
+// extractLiveCookies reads document.cookie from a live Chrome session.
+// Tries: agent-browser → browser-use → raw CDP, in that order.
+func extractLiveCookies(domain string) (string, error) {
+ targetURL := "https://" + strings.TrimPrefix(domain, ".")
+
+ // 1. Try browser-use --connect (preferred — has full DOM/cookie access)
+ if _, err := exec.LookPath("browser-use"); err == nil {
+ cookies, err := extractViaBrowserUse(targetURL)
+ if err == nil && cookies != "" {
+ return cookies, nil
+ }
+ }
+
+ // 2. Try agent-browser --auto-connect (fallback — limited eval in auto-connect mode)
+ if _, err := exec.LookPath("agent-browser"); err == nil {
+ cookies, err := extractViaAgentBrowser(targetURL)
+ if err == nil && cookies != "" {
+ return cookies, nil
+ }
+ }
+
+ // 3. Try raw CDP on common debug ports
+ for _, port := range []string{"9222", "9229"} {
+ cookies, err := extractViaCDP(targetURL, port)
+ if err == nil && cookies != "" {
+ return cookies, nil
+ }
+ }
+
+ return "", fmt.Errorf("no live browser connection available (tried agent-browser, browser-use, CDP)")
+}
+
+func extractViaAgentBrowser(targetURL string) (string, error) {
+ // Open the target URL (attaches to running Chrome)
+ openCmd := exec.Command("agent-browser", "--auto-connect", "open", targetURL)
+ openCmd.Stderr = os.Stderr
+ if err := openCmd.Run(); err != nil {
+ return "", fmt.Errorf("agent-browser open: %w", err)
+ }
+
+ // Read document.cookie via eval
+ var out bytes.Buffer
+ evalCmd := exec.Command("agent-browser", "eval", "document.cookie")
+ evalCmd.Stdout = &out
+ evalCmd.Stderr = os.Stderr
+ if err := evalCmd.Run(); err != nil {
+ closeCmd := exec.Command("agent-browser", "close")
+ _ = closeCmd.Run()
+ return "", fmt.Errorf("agent-browser eval: %w", err)
+ }
+
+ // Close the daemon
+ closeCmd := exec.Command("agent-browser", "close")
+ _ = closeCmd.Run()
+
+ return strings.TrimSpace(out.String()), nil
+}
+
+func extractViaBrowserUse(targetURL string) (string, error) {
+ // Try connecting to running Chrome
+ openCmd := exec.Command("browser-use", "--connect", "open", targetURL)
+ openCmd.Stderr = os.Stderr
+ if err := openCmd.Run(); err != nil {
+ return "", fmt.Errorf("browser-use open: %w", err)
+ }
+
+ var out bytes.Buffer
+ evalCmd := exec.Command("browser-use", "eval", "document.cookie")
+ evalCmd.Stdout = &out
+ evalCmd.Stderr = os.Stderr
+ if err := evalCmd.Run(); err != nil {
+ closeCmd := exec.Command("browser-use", "close")
+ _ = closeCmd.Run()
+ return "", fmt.Errorf("browser-use eval: %w", err)
+ }
+
+ closeCmd := exec.Command("browser-use", "close")
+ _ = closeCmd.Run()
+
+ result := strings.TrimSpace(out.String())
+ // browser-use eval prefixes with "result: "
+ result = strings.TrimPrefix(result, "result: ")
+ return result, nil
+}
+
+func extractViaCDP(targetURL, port string) (string, error) {
+ // Discover available tabs via CDP JSON endpoint
+ listURL := fmt.Sprintf("http://localhost:%s/json", port)
+ client := &http.Client{Timeout: 2 * time.Second}
+ resp, err := client.Get(listURL)
+ if err != nil {
+ return "", fmt.Errorf("CDP not available on port %s", port)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ // Find a tab matching the target domain
+ domain := strings.TrimPrefix(strings.TrimPrefix(targetURL, "https://"), "http://")
+ var tabs []struct {
+ URL string `json:"url"`
+ WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
+ }
+ if err := json.Unmarshal(body, &tabs); err != nil {
+ return "", fmt.Errorf("parsing CDP tabs: %w", err)
+ }
+
+ var wsURL string
+ for _, tab := range tabs {
+ if strings.Contains(tab.URL, domain) {
+ wsURL = tab.WebSocketDebuggerURL
+ break
+ }
+ }
+ if wsURL == "" {
+ return "", fmt.Errorf("no Chrome tab found for %s", domain)
+ }
+
+ return evalDocumentCookieViaCDP(wsURL, domain)
+}
+
+// evalDocumentCookieViaCDP opens the per-tab DevTools WebSocket and runs a
+// single Runtime.evaluate of `document.cookie`. Bounded by a single 5-second
+// wall-clock deadline shared across the dial, write, and all subsequent frame
+// reads — net.Conn.SetReadDeadline is a single deadline covering all reads on
+// the connection, not a per-message reset, so heavy CDP event traffic chips
+// away at the same budget. Returns the cookie string verbatim (no parsing) so
+// the existing extractLiveCookies pipeline can hand it to refreshStoredBrowserCookies.
+func evalDocumentCookieViaCDP(wsURL, domain string) (string, error) {
+ dialer := websocket.Dialer{HandshakeTimeout: 2 * time.Second}
+ conn, _, err := dialer.Dial(wsURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("CDP WebSocket dial failed for %s: %w", domain, err)
+ }
+ defer conn.Close()
+
+ deadline := time.Now().Add(5 * time.Second)
+ _ = conn.SetWriteDeadline(deadline)
+ _ = conn.SetReadDeadline(deadline)
+
+ const evalID = 1
+ evalReq := map[string]any{
+ "id": evalID,
+ "method": "Runtime.evaluate",
+ "params": map[string]any{
+ "expression": "document.cookie",
+ "returnByValue": true,
+ "awaitPromise": true,
+ },
+ }
+ if err := conn.WriteJSON(evalReq); err != nil {
+ return "", fmt.Errorf("CDP Runtime.evaluate write failed for %s: %w", domain, err)
+ }
+
+ // Drain frames until we see our response. CDP can interleave events from
+ // other domains (Network, Page) on the same socket if those domains were
+ // enabled in this tab by another client; ignore them and keep reading
+ // until the shared 5-second deadline above fires. Heavy event traffic can
+ // exhaust the budget before our response arrives — that's by design (a
+ // stuck tab fails fast) rather than a per-read timeout.
+ for {
+ var msg struct {
+ ID int `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := conn.ReadJSON(&msg); err != nil {
+ return "", fmt.Errorf("CDP Runtime.evaluate read failed for %s: %w", domain, err)
+ }
+ if msg.ID != evalID {
+ continue
+ }
+ if msg.Error != nil {
+ return "", fmt.Errorf("CDP Runtime.evaluate error for %s: %s", domain, msg.Error.Message)
+ }
+ var result struct {
+ Result struct {
+ Type string `json:"type"`
+ Value string `json:"value"`
+ } `json:"result"`
+ ExceptionDetails *struct {
+ Text string `json:"text"`
+ } `json:"exceptionDetails"`
+ }
+ if err := json.Unmarshal(msg.Result, &result); err != nil {
+ return "", fmt.Errorf("CDP Runtime.evaluate result parse failed for %s: %w", domain, err)
+ }
+ if result.ExceptionDetails != nil {
+ return "", fmt.Errorf("CDP page threw evaluating document.cookie for %s: %s", domain, result.ExceptionDetails.Text)
+ }
+ return result.Result.Value, nil
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/best.go b/library/productivity/human-goat/internal/cli/best.go
new file mode 100644
index 0000000000..a5bb52e176
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/best.go
@@ -0,0 +1,309 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// pp:data-source live
+// Novel command scaffold. Implement the RunE body before shipping.
+// generate --force preserves implemented bodies; untouched TODO scaffolds may refresh.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/pricing"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/taskrabbit"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+func newNovelBestCmd(flags *rootFlags) *cobra.Command {
+ var flagOn string
+ var flagMinRating float64
+ var flagMaxRate float64
+ var flagLat float64
+ var flagLng float64
+ var flagState string
+ var flagLimit int
+
+ cmd := &cobra.Command{
+ Use: "best ",
+ Short: "Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate",
+ Example: "human-goat-pp-cli best help moving --on saturday --min-rating 4.9 --lat 37.7749 --lng -122.4194 --agent",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && !commandHasChangedFlags(cmd) {
+ return cmd.Help()
+ }
+ query := strings.TrimSpace(strings.Join(args, " "))
+ if dryRunOK(flags) {
+ if query == "" {
+ query = ""
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "would rank Taskers for %s\n", query)
+ return nil
+ }
+ if query == "" {
+ return usageErr(fmt.Errorf("missing job-query"))
+ }
+ if !cmd.Flags().Changed("lat") || !cmd.Flags().Changed("lng") {
+ return usageErr(fmt.Errorf("pass --lat and --lng for your location"))
+ }
+ if flagLimit < 0 {
+ return usageErr(fmt.Errorf("--limit must be non-negative"))
+ }
+
+ date, err := parseOnDate(flagOn)
+ if err != nil {
+ return usageErr(err)
+ }
+
+ ctx, cancel := boundCtx(cmd.Context(), flags)
+ defer cancel()
+
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ category, err := resolveTaskRabbitCategory(ctx, c, query)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ tr := taskrabbit.New(c)
+ rows, err := rankedTaskRabbitRecommendations(ctx, tr, category, taskrabbitRankOptions{
+ Date: date,
+ MinRating: flagMinRating,
+ MaxRate: flagMaxRate,
+ Lat: flagLat,
+ Lng: flagLng,
+ State: flagState,
+ Limit: flagLimit,
+ })
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ return printTaskerRankRows(cmd, flags, rows)
+ },
+ }
+ cmd.Flags().StringVar(&flagOn, "on", "", "Date to search: YYYY-MM-DD, today, tomorrow, or weekday")
+ cmd.Flags().Float64Var(&flagMinRating, "min-rating", 0, "Minimum Tasker rating")
+ cmd.Flags().Float64Var(&flagMaxRate, "max-rate", 0, "Maximum all-in hourly rate in dollars (0 for no ceiling)")
+ cmd.Flags().Float64Var(&flagLat, "lat", 0, "Latitude for TaskRabbit recommendations")
+ cmd.Flags().Float64Var(&flagLng, "lng", 0, "Longitude for TaskRabbit recommendations")
+ cmd.Flags().StringVar(&flagState, "state", "", "State for CA/MA service-fee-only pricing rule")
+ cmd.Flags().IntVar(&flagLimit, "limit", 10, "Maximum number of Taskers to return")
+ return cmd
+}
+
+type taskrabbitCategoryMatch struct {
+ Title string `json:"title"`
+ CategoryName string `json:"category_name"`
+ CategoryID int `json:"category_id"`
+ DefaultTemplateID int `json:"default_template_id"`
+}
+
+type taskrabbitRankOptions struct {
+ Date string
+ MinRating float64
+ MaxRate float64
+ Lat float64
+ Lng float64
+ State string
+ Limit int
+}
+
+type taskerRankRow struct {
+ Name string `json:"name"`
+ AllInHourly float64 `json:"all_in_hourly"`
+ BaseHourly float64 `json:"base_hourly"`
+ Rating float64 `json:"rating"`
+ Reviews int `json:"reviews"`
+ TasksComplete int `json:"tasks_completed"`
+ Elite bool `json:"elite"`
+ NextAvailable string `json:"next_available"`
+ IsFavorite bool `json:"is_favorite"`
+ RecommendationID string `json:"recommendation_id,omitempty"`
+ allInCents int
+ baseCents int
+}
+
+func parseOnDate(s string) (string, error) {
+ clean := strings.ToLower(strings.TrimSpace(s))
+ now := time.Now()
+ if clean == "" || clean == "today" {
+ return now.Format("2006-01-02"), nil
+ }
+ if clean == "tomorrow" {
+ return now.AddDate(0, 0, 1).Format("2006-01-02"), nil
+ }
+ if parsed, err := time.Parse("2006-01-02", clean); err == nil {
+ return parsed.Format("2006-01-02"), nil
+ }
+
+ weekdays := map[string]time.Weekday{
+ "sunday": time.Sunday,
+ "sun": time.Sunday,
+ "monday": time.Monday,
+ "mon": time.Monday,
+ "tuesday": time.Tuesday,
+ "tue": time.Tuesday,
+ "tues": time.Tuesday,
+ "wednesday": time.Wednesday,
+ "wed": time.Wednesday,
+ "thursday": time.Thursday,
+ "thu": time.Thursday,
+ "thur": time.Thursday,
+ "thurs": time.Thursday,
+ "friday": time.Friday,
+ "fri": time.Friday,
+ "saturday": time.Saturday,
+ "sat": time.Saturday,
+ }
+ target, ok := weekdays[clean]
+ if !ok {
+ return "", fmt.Errorf("invalid --on %q: expected YYYY-MM-DD, today, tomorrow, or weekday", s)
+ }
+ daysAhead := (int(target) - int(now.Weekday()) + 7) % 7
+ if daysAhead == 0 {
+ daysAhead = 7
+ }
+ return now.AddDate(0, 0, daysAhead).Format("2006-01-02"), nil
+}
+
+func resolveTaskRabbitCategory(ctx context.Context, c *client.Client, query string) (taskrabbitCategoryMatch, error) {
+ body, err := c.Get(ctx, "/api/v3/web-client/metro_task_template.json", nil)
+ if err != nil {
+ return taskrabbitCategoryMatch{}, err
+ }
+ var decoded struct {
+ InitialTaskTemplates []taskrabbitCategoryMatch `json:"initial_task_templates"`
+ }
+ if err := json.Unmarshal(body, &decoded); err != nil {
+ return taskrabbitCategoryMatch{}, fmt.Errorf("decode TaskRabbit task templates: %w", err)
+ }
+
+ needle := strings.ToLower(strings.TrimSpace(query))
+ titles := make([]string, 0, len(decoded.InitialTaskTemplates))
+ for _, template := range decoded.InitialTaskTemplates {
+ if template.Title != "" {
+ titles = append(titles, template.Title)
+ }
+ if taskrabbitCategoryMatches(needle, template.Title, template.CategoryName) {
+ return template, nil
+ }
+ }
+ sort.Strings(titles)
+ return taskrabbitCategoryMatch{}, usageErr(fmt.Errorf("no TaskRabbit category matched %q (available titles: %s)", query, strings.Join(titles, ", ")))
+}
+
+func taskrabbitCategoryMatches(query, title, categoryName string) bool {
+ if query == "" {
+ return false
+ }
+ title = strings.ToLower(strings.TrimSpace(title))
+ categoryName = strings.ToLower(strings.TrimSpace(categoryName))
+ return strings.Contains(title, query) ||
+ strings.Contains(categoryName, query) ||
+ (title != "" && strings.Contains(query, title)) ||
+ (categoryName != "" && strings.Contains(query, categoryName))
+}
+
+func rankedTaskRabbitRecommendations(ctx context.Context, tr *taskrabbit.Client, category taskrabbitCategoryMatch, opts taskrabbitRankOptions) ([]taskerRankRow, error) {
+ taskers, _, recommendationID, err := tr.Recommendations(ctx, taskrabbit.BuildRecommendationsInput(category.CategoryID, category.DefaultTemplateID, opts.Lat, opts.Lng, []string{opts.Date}))
+ if err != nil {
+ return nil, err
+ }
+
+ rows := make([]taskerRankRow, 0, len(taskers))
+ for _, tasker := range taskers {
+ if opts.MinRating > 0 && tasker.RabbitRating < opts.MinRating {
+ continue
+ }
+ breakdown := pricing.AllIn(tasker.PosterHourlyRateCents, opts.State)
+ allInHourly := float64(breakdown.AllInCents) / 100.0
+ if opts.MaxRate > 0 && allInHourly > opts.MaxRate {
+ continue
+ }
+ rows = append(rows, taskerRankRow{
+ Name: taskerDisplayName(tasker),
+ AllInHourly: allInHourly,
+ BaseHourly: float64(breakdown.BaseCents) / 100.0,
+ Rating: tasker.RabbitRating,
+ Reviews: tasker.RabbitReviews,
+ TasksComplete: tasker.CategoryInvoicesCount,
+ Elite: tasker.Elite,
+ NextAvailable: tasker.NextAvailableAt,
+ IsFavorite: tasker.IsFavorite,
+ RecommendationID: recommendationID,
+ allInCents: breakdown.AllInCents,
+ baseCents: breakdown.BaseCents,
+ })
+ }
+
+ sort.SliceStable(rows, func(i, j int) bool {
+ if rows[i].allInCents != rows[j].allInCents {
+ return rows[i].allInCents < rows[j].allInCents
+ }
+ if rows[i].Rating != rows[j].Rating {
+ return rows[i].Rating > rows[j].Rating
+ }
+ return rows[i].TasksComplete > rows[j].TasksComplete
+ })
+ if opts.Limit > 0 && len(rows) > opts.Limit {
+ rows = rows[:opts.Limit]
+ }
+ return rows, nil
+}
+
+func taskerDisplayName(tasker taskrabbit.Tasker) string {
+ if strings.TrimSpace(tasker.DisplayName) != "" {
+ return tasker.DisplayName
+ }
+ return tasker.FirstName
+}
+
+func printTaskerRankRows(cmd *cobra.Command, flags *rootFlags, rows []taskerRankRow) error {
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
+ }
+ if len(rows) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "no qualifying Taskers after filtering")
+ return nil
+ }
+ tableRows := make([][]string, 0, len(rows))
+ for _, row := range rows {
+ tableRows = append(tableRows, []string{
+ row.Name,
+ pricing.FormatCents(row.allInCents),
+ pricing.FormatCents(row.baseCents),
+ fmt.Sprintf("%.2f", row.Rating),
+ fmt.Sprintf("%d", row.Reviews),
+ fmt.Sprintf("%d", row.TasksComplete),
+ fmt.Sprintf("%t", row.Elite),
+ row.NextAvailable,
+ fmt.Sprintf("%t", row.IsFavorite),
+ })
+ }
+ return flags.printTable(cmd, []string{"NAME", "ALL-IN/HR", "BASE/HR", "RATING", "REVIEWS", "TASKS", "ELITE", "NEXT", "FAVORITE"}, tableRows)
+}
+
+func printTaskerRankRow(cmd *cobra.Command, flags *rootFlags, row taskerRankRow) error {
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), row, flags)
+ }
+ return printTaskerRankRows(cmd, flags, []taskerRankRow{row})
+}
+
+func commandHasChangedFlags(cmd *cobra.Command) bool {
+ changed := false
+ visit := func(_ *pflag.Flag) {
+ changed = true
+ }
+ cmd.Flags().Visit(visit)
+ cmd.InheritedFlags().Visit(visit)
+ return changed
+}
diff --git a/library/productivity/human-goat/internal/cli/best_test.go b/library/productivity/human-goat/internal/cli/best_test.go
new file mode 100644
index 0000000000..909466844f
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/best_test.go
@@ -0,0 +1,32 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// cli-printing-press: novel-scaffold-test
+// Novel command scaffold tests. Keep the wiring smoke test and add behavior cases as needed.
+
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// TestNovelBestHelpWires smoke-tests that the best command
+// resolves at runtime and renders useful --help output. Catches wiring
+// regressions (missing AddCommand, panicking RunE on --help, etc.) before
+// review. Keep this smoke test when adding behavior-specific cases.
+func TestNovelBestHelpWires(t *testing.T) {
+ cmd := RootCmd()
+ cmd.SetArgs([]string{"best", "--help"})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("best --help error = %v (novel command not wired correctly?)", err)
+ }
+ help := out.String()
+ for _, want := range []string{"Usage:", "best"} {
+ if !strings.Contains(help, want) {
+ t.Fatalf("best --help missing %q in output:\n%s", want, help)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/call.go b/library/productivity/human-goat/internal/cli/call.go
new file mode 100644
index 0000000000..8d54c49a35
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/call.go
@@ -0,0 +1,98 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+// pp:data-source live
+
+package cli
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/magic"
+)
+
+type magicCallOutput struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Title string `json:"title"`
+ TrackCommand string `json:"track_command"`
+}
+
+func newMagicCallCmd(flags *rootFlags) *cobra.Command {
+ var number string
+ var ask string
+
+ cmd := &cobra.Command{
+ Use: "call ",
+ Short: "Ask Magic to place a phone call and report back",
+ Example: ` human-goat-pp-cli call +14155551212 "Are you open on July 4?"
+ human-goat-pp-cli call --number +14155551212 --ask "Do you have patio seating?"`,
+ Annotations: map[string]string{"pp:no-error-path-probe": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && cmd.Flags().NFlag() == 0 {
+ return cmd.Help()
+ }
+ if len(args) > 2 {
+ return usageErr(fmt.Errorf("call accepts at most two positional arguments: "))
+ }
+ if len(args) > 0 {
+ number = args[0]
+ }
+ if len(args) > 1 {
+ ask = args[1]
+ }
+ number = strings.TrimSpace(number)
+ ask = strings.TrimSpace(ask)
+ if dryRunOK(flags) || cliutil.IsVerifyEnv() || cliutil.IsDogfoodEnv() {
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{"dry_run": true, "would_call": number, "ask": ask}, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "would call %s and ask: %s\n", number, ask)
+ return nil
+ }
+ if number == "" {
+ return usageErr(fmt.Errorf("number is required"))
+ }
+ if ask == "" {
+ return usageErr(fmt.Errorf("ask is required"))
+ }
+
+ ctx, cancel := boundCtx(cmd.Context(), flags)
+ defer cancel()
+
+ client, err := magic.NewClient()
+ if err != nil {
+ return fmt.Errorf("initialize Magic client: %w", err)
+ }
+ req, err := client.Call(ctx, number, ask)
+ if err != nil {
+ return fmt.Errorf("call with Magic: %w", err)
+ }
+ persistMagicRequest(ctx, req)
+ out := magicCallSummary(req)
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "request id: %s\n", out.ID)
+ fmt.Fprintf(cmd.OutOrStdout(), "track it with: %s\n", out.TrackCommand)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&number, "number", "", "Phone number for Magic to call")
+ cmd.Flags().StringVar(&ask, "ask", "", "Question or request Magic should ask on the call")
+ return cmd
+}
+
+func magicCallSummary(req *magic.Request) magicCallOutput {
+ out := magicCallOutput{}
+ if req != nil {
+ out.ID = req.ID
+ out.Status = req.Status
+ out.Title = req.Title
+ }
+ out.TrackCommand = fmt.Sprintf("human-goat-pp-cli track %s", out.ID)
+ return out
+}
diff --git a/library/productivity/human-goat/internal/cli/cancel.go b/library/productivity/human-goat/internal/cli/cancel.go
new file mode 100644
index 0000000000..db5770f305
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/cancel.go
@@ -0,0 +1,223 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// pp:data-source live
+// Novel command scaffold. Implement the RunE body before shipping.
+// generate --force preserves implemented bodies; untouched TODO scaffolds may refresh.
+
+package cli
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/taskrabbit"
+
+ "github.com/spf13/cobra"
+)
+
+func newNovelCancelCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "cancel ",
+ Short: "Cancels a booking and confirms it landed by re-reading status",
+ Example: "human-goat-pp-cli cancel task_abc123 --agent",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && !commandHasChangedFlags(cmd) {
+ return cmd.Help()
+ }
+ if len(args) == 0 {
+ return usageErr(fmt.Errorf("missing booking-id"))
+ }
+ if len(args) > 1 {
+ return usageErr(fmt.Errorf("cancel accepts one booking-id"))
+ }
+ id := strings.TrimSpace(args[0])
+ if id == "" {
+ return usageErr(fmt.Errorf("missing booking-id"))
+ }
+ if dryRunOK(flags) || cliutil.IsVerifyEnv() {
+ fmt.Fprintf(cmd.OutOrStdout(), "would cancel booking %s and re-read status to verify\n", id)
+ return nil
+ }
+
+ ctx, cancel := boundCtx(cmd.Context(), flags)
+ defer cancel()
+
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ tr := taskrabbit.New(c)
+
+ jobID, convErr := strconv.Atoi(id)
+ if convErr != nil {
+ return usageErr(fmt.Errorf("booking-id must be numeric (the jobId from `tasks list`), got %q", id))
+ }
+
+ // Look up the booking to get its rabbitId (taskers[0].id), which
+ // cancelTask requires alongside jobId. Page through active bookings so
+ // a booking past the first page is still found.
+ active, err := listAllActiveBookings(ctx, tr)
+ if err != nil {
+ return classifyAPIError(fmt.Errorf("cancel: look up booking %d: %w", jobID, err), flags)
+ }
+ var rabbitID int
+ var appointment string
+ var found bool
+ for _, b := range active {
+ if b.JobID == jobID {
+ rabbitID = b.RabbitID
+ appointment = b.Appointment
+ found = true
+ break
+ }
+ }
+ if !found {
+ return classifyAPIError(fmt.Errorf("cancel: no active booking with id %d (run `tasks list` to see current bookings)", jobID), flags)
+ }
+
+ // A missing CSRF token guarantees a 403 on the mutation, so fail fast
+ // with the root cause rather than surfacing it as a downstream API error.
+ token, tokenErr := csrfToken(ctx, flags)
+ if tokenErr != nil {
+ return classifyAPIError(fmt.Errorf("cancel TaskRabbit booking %d: obtain CSRF token: %w", jobID, tokenErr), flags)
+ }
+
+ if _, err := tr.CancelTask(ctx, jobID, rabbitID, "Plans changed, no longer need the help", token); err != nil {
+ return classifyAPIError(fmt.Errorf("cancel TaskRabbit booking %d: %w", jobID, err), flags)
+ }
+
+ // Verify: re-read active bookings (all pages); the cancelled booking must be gone.
+ after, err := listAllActiveBookings(ctx, tr)
+ if err != nil {
+ return classifyAPIError(fmt.Errorf("cancel TaskRabbit booking %d succeeded, but verify re-read failed: %w", jobID, err), flags)
+ }
+ stillActive := false
+ for _, b := range after {
+ if b.JobID == jobID {
+ stillActive = true
+ break
+ }
+ }
+ result := cancelResult{
+ BookingID: id,
+ CancelResponseOK: true,
+ }
+ if stillActive {
+ result.VerifiedStatus = "still-active"
+ result.Note = joinNotes(result.Note, "WARNING: booking still appears active after cancel; verify in the app")
+ } else {
+ result.VerifiedStatus = "cancelled"
+ result.Note = joinNotes(result.Note, "verified: booking no longer active", refundStatusNote(appointment))
+ }
+ return printCancelResult(cmd, flags, result)
+ },
+ }
+ return cmd
+}
+
+type cancelResult struct {
+ BookingID string `json:"booking_id"`
+ CancelResponseOK bool `json:"cancel_response_ok"`
+ VerifiedStatus string `json:"verified_status"`
+ Note string `json:"note,omitempty"`
+}
+
+// listAllActiveBookings pages through active TaskRabbit bookings so a booking
+// past the first page is still found. Capped so a paging bug can't loop forever.
+func listAllActiveBookings(ctx context.Context, tr *taskrabbit.Client) ([]taskrabbit.Booking, error) {
+ const perPage = 50
+ const maxPages = 40 // 2000 active bookings is far beyond any real account
+ var all []taskrabbit.Booking
+ for page := 1; page <= maxPages; page++ {
+ batch, err := tr.ListTasks(ctx, page, perPage, map[string]any{"status": "active"}, "en-US")
+ if err != nil {
+ return nil, err
+ }
+ all = append(all, batch...)
+ if len(batch) < perPage {
+ break
+ }
+ }
+ return all, nil
+}
+
+func csrfToken(ctx context.Context, flags *rootFlags) (string, error) {
+ c, err := flags.newClient()
+ if err != nil {
+ return "", err
+ }
+ body, err := c.GetWithHeadersNoCache(ctx, "/dashboard", nil, map[string]string{
+ client.BinaryResponseHeader: "true",
+ "Accept": "text/html,*/*",
+ })
+ if err != nil {
+ return "", err
+ }
+ // TaskRabbit renders `` (self-closing,
+ // space before />), so match up to the closing quote rather than a literal ">".
+ matches := regexp.MustCompile(`= 24*time.Hour {
+ return "cancelled >=24h before the appointment; deposit is refundable"
+ }
+ return "cancelled within 24h of the appointment; deposit may be forfeited"
+}
+
+// parseAppointmentTime tolerates the common timestamp shapes TaskRabbit returns.
+func parseAppointmentTime(s string) (time.Time, bool) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return time.Time{}, false
+ }
+ for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02"} {
+ if t, err := time.Parse(layout, s); err == nil {
+ return t, true
+ }
+ }
+ return time.Time{}, false
+}
diff --git a/library/productivity/human-goat/internal/cli/cancel_test.go b/library/productivity/human-goat/internal/cli/cancel_test.go
new file mode 100644
index 0000000000..9d591b9668
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/cancel_test.go
@@ -0,0 +1,32 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// cli-printing-press: novel-scaffold-test
+// Novel command scaffold tests. Keep the wiring smoke test and add behavior cases as needed.
+
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// TestNovelCancelHelpWires smoke-tests that the cancel command
+// resolves at runtime and renders useful --help output. Catches wiring
+// regressions (missing AddCommand, panicking RunE on --help, etc.) before
+// review. Keep this smoke test when adding behavior-specific cases.
+func TestNovelCancelHelpWires(t *testing.T) {
+ cmd := RootCmd()
+ cmd.SetArgs([]string{"cancel", "--help"})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("cancel --help error = %v (novel command not wired correctly?)", err)
+ }
+ help := out.String()
+ for _, want := range []string{"Usage:", "cancel"} {
+ if !strings.Contains(help, want) {
+ t.Fatalf("cancel --help missing %q in output:\n%s", want, help)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/channel_workflow.go b/library/productivity/human-goat/internal/cli/channel_workflow.go
new file mode 100644
index 0000000000..638dcbf1d8
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/channel_workflow.go
@@ -0,0 +1,165 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+ "github.com/spf13/cobra"
+)
+
+func newWorkflowCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "workflow",
+ Short: "Compound workflows that combine multiple API operations",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: parentNoSubcommandRunE(flags),
+ }
+ cmd.AddCommand(newWorkflowArchiveCmd(flags))
+ cmd.AddCommand(newWorkflowStatusCmd(flags))
+
+ return cmd
+}
+func newWorkflowArchiveCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+ var full bool
+
+ cmd := &cobra.Command{
+ Use: "archive",
+ Short: "Sync all resources to local store for offline access and search",
+ Long: `Archive fetches all syncable resources from the API and stores them in a
+local SQLite database. Supports incremental sync (only new data since last run)
+and full resync. After archiving, use 'search' for instant full-text search.`,
+ Example: ` # Archive all resources
+ human-goat-pp-cli workflow archive
+
+ # Full re-archive (ignore previous sync state)
+ human-goat-pp-cli workflow archive --full`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c.NoCache = true
+
+ if dbPath == "" {
+ dbPath = defaultDBPath("human-goat-pp-cli")
+ }
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("opening store: %w", err)
+ }
+ defer s.Close()
+
+ resources := []string{"categories", "taskers", "taskers-favorite", "taskers-tasker-suggestions"}
+ totalSynced := 0
+ syncEventWriter := cmd.OutOrStdout()
+ if flags.asJSON {
+ syncEventWriter = cmd.ErrOrStderr()
+ }
+
+ // --full clears the cursor here because syncResource reads
+ // existingCursor unconditionally; its full param only gates the
+ // since filter, not cursor reset. Mirrors newSyncCmd's pattern.
+ if full {
+ for _, resource := range resources {
+ _ = s.SaveSyncState(resource, "", 0)
+ }
+ }
+
+ for _, resource := range resources {
+ res := syncResource(cmd.Context(), c, s, resource, "", full, 100, false, false, nil, syncEventWriter)
+ if res.Err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), " %s: error: %v\n", resource, res.Err)
+ continue
+ }
+ if res.Warn != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), " %s: warning: %v\n", resource, res.Warn)
+ continue
+ }
+ totalSynced += res.Count
+ fmt.Fprintf(cmd.ErrOrStderr(), " %s: %d synced\n", resource, res.Count)
+ }
+
+ if flags.asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(map[string]any{
+ "resources_synced": len(resources),
+ "total_items": totalSynced,
+ "store_path": dbPath,
+ "timestamp": time.Now().UTC().Format(time.RFC3339),
+ })
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Archived %d items across %d resources to %s\n", totalSynced, len(resources), dbPath)
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ cmd.Flags().BoolVar(&full, "full", false, "Full re-archive (ignore previous sync state)")
+
+ return cmd
+}
+
+func newWorkflowStatusCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Show local archive status and sync state for all resources",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ Example: ` # Show archive status
+ human-goat-pp-cli workflow status
+
+ # Show status as JSON
+ human-goat-pp-cli workflow status --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dbPath == "" {
+ dbPath = defaultDBPath("human-goat-pp-cli")
+ }
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("opening store: %w", err)
+ }
+ defer s.Close()
+
+ status, err := s.Status()
+ if err != nil {
+ return err
+ }
+
+ if flags.asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(status)
+ }
+
+ if len(status) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No archived data. Run 'workflow archive' to sync.")
+ return nil
+ }
+
+ fmt.Fprintln(cmd.OutOrStdout(), "Archive Status:")
+ total := 0
+ for resource, count := range status {
+ fmt.Fprintf(cmd.OutOrStdout(), " %-30s %d items\n", resource, count)
+ total += count
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "\n Total: %d items\n", total)
+ fmt.Fprintf(cmd.OutOrStdout(), " Store: %s\n", dbPath)
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+
+ return cmd
+}
+
+// defaultDBPath is defined in helpers.go
diff --git a/library/productivity/human-goat/internal/cli/data_source.go b/library/productivity/human-goat/internal/cli/data_source.go
new file mode 100644
index 0000000000..fc99437d85
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/data_source.go
@@ -0,0 +1,649 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+const networkFallbackReason = "api_unreachable"
+
+func unsupportedDataSourceError(strategy, requested string) error {
+ switch strategy {
+ case "local":
+ return fmt.Errorf("no live equivalent for this command (requested %q); use --data-source local or --data-source auto", requested)
+ case "live":
+ return fmt.Errorf("no local data source for this command (requested %q); use --data-source live or --data-source auto", requested)
+ default:
+ return fmt.Errorf("unsupported --data-source %q for strategy %q", requested, strategy)
+ }
+}
+
+func validateDataSourceStrategy(flags *rootFlags, strategy string) error {
+ switch strategy {
+ case "", "auto":
+ return nil
+ case "local":
+ if flags.dataSource == "live" {
+ return unsupportedDataSourceError(strategy, flags.dataSource)
+ }
+ case "live":
+ if flags.dataSource == "local" {
+ return unsupportedDataSourceError(strategy, flags.dataSource)
+ }
+ }
+ return nil
+}
+
+// isNetworkError returns true for errors caused by network connectivity issues
+// (DNS, connection refused, timeout). HTTP 4xx/5xx errors are NOT network errors.
+func isNetworkError(err error) bool {
+ if err == nil {
+ return false
+ }
+ var urlErr *url.Error
+ if As(err, &urlErr) {
+ // url.Error wraps the underlying network error
+ err = urlErr.Err
+ }
+ var netErr *net.OpError
+ if As(err, &netErr) {
+ return true
+ }
+ var dnsErr *net.DNSError
+ if As(err, &dnsErr) {
+ return true
+ }
+ // Check for common network error strings
+ msg := err.Error()
+ return strings.Contains(msg, "connection refused") ||
+ strings.Contains(msg, "no such host") ||
+ strings.Contains(msg, "network is unreachable") ||
+ strings.Contains(msg, "i/o timeout") ||
+ strings.Contains(msg, "TLS handshake timeout")
+}
+
+// openStoreForRead opens the local SQLite store read-only for reading.
+// Returns nil, nil if the database file does not exist (no sync has been run).
+//
+// Read paths open with store.OpenReadOnly: no MkdirAll, no migration loop, and
+// no write lock, so a read concurrent with a sync cannot block on the writer
+// and a read command never runs a schema migration as a side effect. ctx is
+// threaded into OpenReadOnlyContext so a cancelled command (SIGINT, deadline)
+// interrupts the driver-init SQLITE_BUSY retry rather than blocking on it.
+func openStoreForRead(ctx context.Context, cliName string) (*store.Store, error) {
+ dbPath := defaultDBPath(cliName)
+ if _, err := os.Stat(dbPath); os.IsNotExist(err) {
+ return nil, nil
+ }
+ return store.OpenReadOnlyContext(ctx, dbPath)
+}
+
+// localProvenance builds a DataProvenance for local data reads.
+func localProvenance(db *store.Store, resourceType, reason string) DataProvenance {
+ prov := DataProvenance{
+ Source: "local",
+ Reason: reason,
+ ResourceType: resourceType,
+ }
+ _, lastSynced, _, err := db.GetSyncState(resourceType)
+ if err == nil && !lastSynced.IsZero() {
+ prov.SyncedAt = &lastSynced
+ }
+ return prov
+}
+
+func attachFreshness(prov DataProvenance, flags *rootFlags) DataProvenance {
+ if flags != nil {
+ prov.Freshness = flags.freshnessMeta
+ }
+ return prov
+}
+
+// resolveRead dispatches a GET request to either the live API or local store
+// based on the --data-source flag. Returns the response data and provenance metadata.
+//
+// Parameters:
+// - c: the HTTP client for live API calls
+// - flags: root flags containing dataSource setting
+// - resourceType: the store resource type name (e.g., "links", "domains")
+// - isList: true for list endpoints, false for get-by-ID endpoints
+// - path: the API path (e.g., "/links" or "/links/abc123")
+// - params: query parameters for the API call
+// - headers: per-endpoint required headers (e.g. cal-api-version, Stripe-Version)
+// baked in by the command template at codegen time. Pass nil when the endpoint
+// declares no per-endpoint header overrides. Without this parameter, store-backed
+// reads on per-endpoint-versioned APIs silently get the wrong response shape
+// (cal-com retro #334 F1).
+func resolveRead(ctx context.Context, c *client.Client, flags *rootFlags, resourceType string, isList bool, path string, params map[string]string, headers map[string]string, hintWriter io.Writer) (json.RawMessage, DataProvenance, error) {
+ return resolveReadWithResponsePath(ctx, c, flags, resourceType, isList, path, params, headers, "", hintWriter)
+}
+
+func resolveReadWithResponsePath(ctx context.Context, c *client.Client, flags *rootFlags, resourceType string, isList bool, path string, params map[string]string, headers map[string]string, responsePath string, hintWriter io.Writer) (json.RawMessage, DataProvenance, error) {
+ return resolveReadWithStrategyAndResponsePath(ctx, c, flags, "auto", resourceType, isList, path, params, headers, responsePath, hintWriter)
+}
+
+func resolveReadWithStrategy(ctx context.Context, c *client.Client, flags *rootFlags, strategy string, resourceType string, isList bool, path string, params map[string]string, headers map[string]string, hintWriter io.Writer) (json.RawMessage, DataProvenance, error) {
+ return resolveReadWithStrategyAndResponsePath(ctx, c, flags, strategy, resourceType, isList, path, params, headers, "", hintWriter)
+}
+
+func resolveReadWithStrategyAndResponsePath(ctx context.Context, c *client.Client, flags *rootFlags, strategy string, resourceType string, isList bool, path string, params map[string]string, headers map[string]string, responsePath string, hintWriter io.Writer) (json.RawMessage, DataProvenance, error) {
+ return resolveReadWithStrategyResponsePathAndJSONGuard(ctx, c, flags, strategy, resourceType, isList, path, params, headers, responsePath, true, hintWriter)
+}
+
+func resolveReadWithStrategyResponsePathAndJSONGuard(ctx context.Context, c *client.Client, flags *rootFlags, strategy string, resourceType string, isList bool, path string, params map[string]string, headers map[string]string, responsePath string, guardLiveJSON bool, hintWriter io.Writer) (json.RawMessage, DataProvenance, error) {
+ if err := validateDataSourceStrategy(flags, strategy); err != nil {
+ return nil, DataProvenance{}, err
+ }
+ if strategy == "local" {
+ data, prov, err := resolveLocal(ctx, flags, hintWriter, resourceType, isList, path, params, "strategy_local")
+ return data, attachFreshness(prov, flags), err
+ }
+ if strategy == "live" {
+ data, err := c.GetWithHeaders(ctx, path, params, headers)
+ if err != nil {
+ return nil, DataProvenance{}, err
+ }
+ if guardLiveJSON {
+ if err := assertLiveJSONBody(data); err != nil {
+ return nil, DataProvenance{}, err
+ }
+ }
+ data = applyResponsePath(data, responsePath)
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
+ }
+ switch flags.dataSource {
+ case "local":
+ data, prov, err := resolveLocal(ctx, flags, hintWriter, resourceType, isList, path, params, "user_requested")
+ return data, attachFreshness(prov, flags), err
+
+ case "live":
+ data, err := c.GetWithHeaders(ctx, path, params, headers)
+ if err != nil {
+ return nil, DataProvenance{}, err
+ }
+ if guardLiveJSON {
+ if err := assertLiveJSONBody(data); err != nil {
+ return nil, DataProvenance{}, err
+ }
+ }
+ data = applyResponsePath(data, responsePath)
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
+
+ default: // "auto"
+ data, err := c.GetWithHeaders(ctx, path, params, headers)
+ if err == nil {
+ if guardLiveJSON {
+ if err := assertLiveJSONBody(data); err != nil {
+ return nil, DataProvenance{}, err
+ }
+ }
+ data = applyResponsePath(data, responsePath)
+ writeThroughCache(ctx, resourceType, data)
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
+ }
+ if !isNetworkError(err) {
+ // HTTP 4xx/5xx errors propagate — not a fallback case
+ return nil, DataProvenance{}, err
+ }
+ // Network error — try local fallback
+ fallbackData, fallbackProv, fallbackErr := resolveLocal(ctx, flags, hintWriter, resourceType, isList, path, params, networkFallbackReason)
+ if fallbackErr != nil {
+ return nil, DataProvenance{}, fmt.Errorf("API unreachable and no local data. Run 'human-goat-pp-cli sync' to enable offline access.\n\nOriginal error: %w", err)
+ }
+ return fallbackData, attachFreshness(fallbackProv, flags), nil
+ }
+}
+
+// resolvePaginatedRead dispatches a paginated GET request to either the live API
+// or local store. When local, skips pagination and returns all synced data. The
+// headers argument carries per-endpoint required headers; pass nil when the
+// endpoint declares no overrides.
+func resolvePaginatedRead(ctx context.Context, c *client.Client, flags *rootFlags, resourceType string, path string, params map[string]string, headers map[string]string, fetchAll bool, cursorParam, paginationType, limitParam, nextCursorPath, hasMoreField string, hintWriter io.Writer) (json.RawMessage, DataProvenance, error) {
+ return resolvePaginatedReadWithStrategy(ctx, c, flags, "auto", resourceType, path, params, headers, fetchAll, cursorParam, paginationType, limitParam, nextCursorPath, hasMoreField, hintWriter)
+}
+
+func resolvePaginatedReadWithStrategy(ctx context.Context, c *client.Client, flags *rootFlags, strategy string, resourceType string, path string, params map[string]string, headers map[string]string, fetchAll bool, cursorParam, paginationType, limitParam, nextCursorPath, hasMoreField string, hintWriter io.Writer) (json.RawMessage, DataProvenance, error) {
+ if err := validateDataSourceStrategy(flags, strategy); err != nil {
+ return nil, DataProvenance{}, err
+ }
+ if strategy == "local" {
+ data, prov, err := resolveLocal(ctx, flags, hintWriter, resourceType, true, path, params, "strategy_local")
+ return data, attachFreshness(prov, flags), err
+ }
+ if strategy == "live" {
+ data, err := paginatedGet(ctx, c, path, params, headers, fetchAll, cursorParam, paginationType, limitParam, nextCursorPath, hasMoreField)
+ if err != nil {
+ return nil, DataProvenance{}, err
+ }
+ if err := assertLiveJSONBody(data); err != nil {
+ return nil, DataProvenance{}, err
+ }
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
+ }
+ switch flags.dataSource {
+ case "local":
+ data, prov, err := resolveLocal(ctx, flags, hintWriter, resourceType, true, path, params, "user_requested")
+ return data, attachFreshness(prov, flags), err
+
+ case "live":
+ data, err := paginatedGet(ctx, c, path, params, headers, fetchAll, cursorParam, paginationType, limitParam, nextCursorPath, hasMoreField)
+ if err != nil {
+ return nil, DataProvenance{}, err
+ }
+ if err := assertLiveJSONBody(data); err != nil {
+ return nil, DataProvenance{}, err
+ }
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
+
+ default: // "auto"
+ data, err := paginatedGet(ctx, c, path, params, headers, fetchAll, cursorParam, paginationType, limitParam, nextCursorPath, hasMoreField)
+ if err == nil {
+ if err := assertLiveJSONBody(data); err != nil {
+ return nil, DataProvenance{}, err
+ }
+ writeThroughCache(ctx, resourceType, data)
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
+ }
+ if !isNetworkError(err) {
+ return nil, DataProvenance{}, err
+ }
+ fallbackData, fallbackProv, fallbackErr := resolveLocal(ctx, flags, hintWriter, resourceType, true, path, params, networkFallbackReason)
+ if fallbackErr != nil {
+ return nil, DataProvenance{}, fmt.Errorf("API unreachable and no local data. Run 'human-goat-pp-cli sync' to enable offline access.\n\nOriginal error: %w", err)
+ }
+ return fallbackData, attachFreshness(fallbackProv, flags), nil
+ }
+}
+
+// listEnvelopeMetadataKeys are top-level keys that, when accompanying a
+// list-wrapper array, suggest the response is a paginated list envelope
+// rather than a detail object. Used by writeThroughCache to decide
+// whether to upsert a single-object body. Any envelope key NOT in this
+// set (and not a list wrapper itself) signals real per-row data, and
+// the envelope is treated as a detail object even when one of its
+// wrapper-named fields happens to be an empty array.
+var listEnvelopeMetadataKeys = map[string]bool{
+ // list wrappers themselves — must stay in sync with pageItemKeys
+ "results": true, "data": true, "items": true,
+ "records": true, "nodes": true, "entries": true, "features": true,
+ "Results": true, "Data": true, "Items": true,
+ "Records": true, "Nodes": true, "Entries": true, "Features": true,
+ // pagination cursors / tokens
+ "next_cursor": true, "nextCursor": true,
+ "next_page_token": true, "nextPageToken": true,
+ "page_token": true, "pageToken": true,
+ "end_cursor": true, "endCursor": true,
+ "start_cursor": true, "startCursor": true,
+ "cursor": true, "after": true, "before": true,
+ // has-more flags and page numbers
+ "has_more": true, "hasMore": true, "has_next": true, "hasNext": true,
+ "next_page": true, "previous_page": true,
+ "page": true, "page_size": true, "per_page": true,
+ // counts / totals
+ "total": true, "count": true, "size": true, "total_count": true, "totalCount": true,
+ // JSend / common status envelopes
+ "success": true, "status": true, "message": true, "error": true,
+ "errors": true, "Errors": true, "warnings": true, "Warnings": true,
+ // wrapper objects
+ "links": true, "meta": true, "pagination": true,
+ "response_metadata": true, "paging": true,
+ // links shape
+ "next": true, "prev": true, "previous": true, "first": true, "last": true,
+}
+
+var writeThroughListWrapperKeys = []string{
+ "data", "results", "items", "records", "nodes", "entries", "features",
+ "Data", "Results", "Items", "Records", "Nodes", "Entries", "Features",
+}
+var writeThroughNestedEnvelopeKeys = []string{"data", "Data", "result", "Result"}
+
+// writeThroughCache upserts live API results into the local SQLite store so
+// FTS search covers everything the user has looked up — not just explicit syncs.
+// Best-effort: failures are silently ignored (the live result already succeeded).
+func writeThroughCache(ctx context.Context, resourceType string, data json.RawMessage) {
+ db, err := store.OpenWithContext(ctx, defaultDBPath("human-goat-pp-cli"))
+ if err != nil {
+ return
+ }
+ defer db.Close()
+
+ // Collect items to upsert from various response shapes
+ var items []json.RawMessage
+
+ // Try direct array first
+ if json.Unmarshal(data, &items) != nil || len(items) == 0 {
+ items = nil
+ // Try object — check for common envelope patterns (results, data, items)
+ var envelope map[string]json.RawMessage
+ if json.Unmarshal(data, &envelope) == nil {
+ matchedListEnvelope := false
+ if extracted, ok := extractWriteThroughListItems(envelope); ok {
+ matchedListEnvelope = true
+ items = extracted
+ }
+ if matchedListEnvelope && len(items) == 0 {
+ return
+ }
+ // Single object detail response: let UpsertBatch's existing
+ // resourceIDFieldOverrides mechanism resolve the primary key.
+ // Guarding on envelope["id"] dropped any API whose PK is named
+ // CertNo / sku / invoiceId / etc. on the floor (#1439).
+ //
+ // Treat the envelope as a list-shaped response only when EVERY
+ // top-level key is either a list-wrapper (results/data/items)
+ // holding a real array, or a known pagination-metadata key.
+ // A detail object that happens to carry an empty wrapper-named
+ // field alongside real data (e.g. {"id":"order","items":[],
+ // "status":"pending"}) must still cache as a single row.
+ if items == nil && !matchedListEnvelope && len(envelope) > 0 {
+ looksLikeListEnvelope := false
+ hasListWrapperArray := false
+ for _, key := range writeThroughListWrapperKeys {
+ raw, ok := envelope[key]
+ if !ok {
+ continue
+ }
+ // json.Unmarshal("null", &arr) succeeds with arr=nil,
+ // so require arr != nil to keep true empty arrays in
+ // the skip branch while letting scalar/null values fall
+ // through as regular field-name collisions.
+ var arr []json.RawMessage
+ if json.Unmarshal(raw, &arr) == nil && arr != nil {
+ hasListWrapperArray = true
+ break
+ }
+ }
+ if hasListWrapperArray {
+ looksLikeListEnvelope = true
+ for k := range envelope {
+ if !listEnvelopeMetadataKeys[k] {
+ looksLikeListEnvelope = false
+ break
+ }
+ }
+ }
+ if !looksLikeListEnvelope {
+ _, _, _ = db.UpsertBatch(resourceType, []json.RawMessage{data})
+ return
+ }
+ }
+ }
+ }
+
+ if len(items) > 0 {
+ _, _, _ = db.UpsertBatch(resourceType, items)
+ }
+}
+
+type writeThroughArrayDecoder func(json.RawMessage) ([]json.RawMessage, bool)
+
+func extractWriteThroughListItems(envelope map[string]json.RawMessage) ([]json.RawMessage, bool) {
+ if items, ok := extractWriteThroughListWrapperItems(envelope, decodeWriteThroughNonEmptyArray); ok {
+ return items, true
+ }
+
+ for _, key := range writeThroughNestedEnvelopeKeys {
+ raw, ok := envelope[key]
+ if !ok || isRawJSONNull(raw) {
+ continue
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(raw, &inner) != nil {
+ continue
+ }
+ if items, ok := extractNestedWriteThroughListItems(inner); ok {
+ return items, true
+ }
+ }
+
+ return extractWriteThroughSingleArraySibling(envelope, decodeWriteThroughNonEmptyArray)
+}
+
+func extractNestedWriteThroughListItems(envelope map[string]json.RawMessage) ([]json.RawMessage, bool) {
+ if items, ok := extractWriteThroughListWrapperItems(envelope, decodeWriteThroughArray); ok {
+ return items, true
+ }
+ return extractWriteThroughSingleArraySibling(envelope, decodeWriteThroughArray)
+}
+
+func extractWriteThroughListWrapperItems(envelope map[string]json.RawMessage, decode writeThroughArrayDecoder) ([]json.RawMessage, bool) {
+ for _, key := range writeThroughListWrapperKeys {
+ raw, ok := envelope[key]
+ if !ok {
+ continue
+ }
+ if items, ok := decode(raw); ok {
+ return items, true
+ }
+ }
+ return nil, false
+}
+
+func extractWriteThroughSingleArraySibling(envelope map[string]json.RawMessage, decode writeThroughArrayDecoder) ([]json.RawMessage, bool) {
+ arrayCount := 0
+ var arrayItems []json.RawMessage
+ for key, raw := range envelope {
+ if listEnvelopeMetadataKeys[key] {
+ continue
+ }
+ if candidate, ok := decode(raw); ok {
+ if !writeThroughArrayItemsAreObjects(candidate) {
+ continue
+ }
+ arrayItems = candidate
+ arrayCount++
+ continue
+ }
+ return nil, false
+ }
+ if arrayCount == 1 {
+ return arrayItems, true
+ }
+ return nil, false
+}
+
+func writeThroughArrayItemsAreObjects(items []json.RawMessage) bool {
+ if len(items) == 0 {
+ return true
+ }
+ var obj map[string]json.RawMessage
+ return json.Unmarshal(items[0], &obj) == nil
+}
+
+func decodeWriteThroughNonEmptyArray(raw json.RawMessage) ([]json.RawMessage, bool) {
+ items, ok := decodeWriteThroughArray(raw)
+ if !ok || len(items) == 0 {
+ return nil, false
+ }
+ return items, true
+}
+
+func decodeWriteThroughArray(raw json.RawMessage) ([]json.RawMessage, bool) {
+ var items []json.RawMessage
+ if json.Unmarshal(raw, &items) != nil || items == nil {
+ return nil, false
+ }
+ return items, true
+}
+
+func isRawJSONNull(raw json.RawMessage) bool {
+ return strings.TrimSpace(string(raw)) == "null"
+}
+
+func writeMutationResponseToStore(ctx context.Context, resourceType string, data json.RawMessage, responsePath string) {
+ items := mutationResponseEntityItems(resourceType, data, responsePath)
+ if len(items) == 0 {
+ return
+ }
+
+ db, err := store.OpenWithContext(ctx, defaultDBPath("human-goat-pp-cli"))
+ if err != nil {
+ return
+ }
+ defer db.Close()
+
+ _, _, _ = db.UpsertBatch(resourceType, items)
+}
+
+func mutationResponseEntityItems(resourceType string, data json.RawMessage, responsePath string) []json.RawMessage {
+ if responsePath != "" {
+ if pathData, ok := responsePayloadAtPath(data, responsePath); ok {
+ data = pathData
+ }
+ }
+
+ if items := mutationResponseItemsFromPayload(resourceType, data); len(items) > 0 {
+ return items
+ }
+
+ data = mutationResponsePayload(data)
+ if items := mutationResponseItemsFromPayload(resourceType, data); len(items) > 0 {
+ return items
+ }
+
+ if responsePath == "" {
+ var envelope map[string]json.RawMessage
+ if json.Unmarshal(data, &envelope) == nil {
+ if _, hasStatus := envelope["status"]; !hasStatus {
+ if raw, ok := envelope["data"]; ok {
+ return mutationResponseItemsFromPayload(resourceType, raw)
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+func mutationResponseItemsFromPayload(resourceType string, data json.RawMessage) []json.RawMessage {
+ if len(bytes.TrimSpace(data)) == 0 {
+ return nil
+ }
+
+ var arr []json.RawMessage
+ if json.Unmarshal(data, &arr) == nil {
+ items := make([]json.RawMessage, 0, len(arr))
+ for _, item := range arr {
+ if mutationResponseHasID(resourceType, item) {
+ items = append(items, item)
+ }
+ }
+ return items
+ }
+
+ if mutationResponseHasID(resourceType, data) {
+ return []json.RawMessage{data}
+ }
+ return nil
+}
+
+func mutationResponsePayload(data json.RawMessage) json.RawMessage {
+ var envelope struct {
+ Status string `json:"status"`
+ Data json.RawMessage `json:"data"`
+ }
+ if err := json.Unmarshal(data, &envelope); err != nil || envelope.Status == "" || envelope.Data == nil {
+ return data
+ }
+ switch envelope.Status {
+ case "success", "ok", "OK", "Success":
+ return envelope.Data
+ default:
+ return data
+ }
+}
+
+func mutationResponseHasID(resourceType string, data json.RawMessage) bool {
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err != nil || len(obj) == 0 {
+ return false
+ }
+ if synthetic, _ := obj["__pp_verify_synthetic__"].(bool); synthetic {
+ return false
+ }
+ return store.ExtractResourceID(resourceType, obj) != ""
+}
+
+// resolveLocal reads data from the local SQLite store.
+// Note: local reads return ALL synced data for the resource type. Endpoint-specific
+// filters (query params, path scoping like /teams/{id}/users) are NOT applied locally.
+// The provenance metadata includes "unscoped":true when params were present but not applied.
+func resolveLocal(ctx context.Context, flags *rootFlags, hintWriter io.Writer, resourceType string, isList bool, path string, params map[string]string, reason string) (json.RawMessage, DataProvenance, error) {
+ db, err := openStoreForRead(ctx, "human-goat-pp-cli")
+ if err != nil {
+ return nil, DataProvenance{}, fmt.Errorf("opening local database: %w\nRun 'human-goat-pp-cli sync' first.", err)
+ }
+ if db == nil {
+ return nil, DataProvenance{}, fmt.Errorf("no local data. Run 'human-goat-pp-cli sync' first")
+ }
+ defer db.Close()
+
+ if flags != nil {
+ emitSyncHints(hintWriter, db, resourceType, flags.maxAge)
+ }
+
+ prov := localProvenance(db, resourceType, reason)
+
+ // Warn if endpoint had filters that local reads can't reproduce
+ if len(params) > 0 {
+ fmt.Fprintf(os.Stderr, "warning: local data is unfiltered — endpoint filters are not applied to cached data\n")
+ }
+
+ if isList {
+ raw, err := db.List(resourceType, 0) // 0 = no limit, return all synced data
+ if err != nil {
+ return nil, DataProvenance{}, fmt.Errorf("querying local store: %w", err)
+ }
+ // Filter out empty/invalid records (empty arrays, null, whitespace-only)
+ // that can end up in the store from pagination boundary artifacts.
+ var items []json.RawMessage
+ for _, r := range raw {
+ trimmed := strings.TrimSpace(string(r))
+ if trimmed == "" || trimmed == "null" || trimmed == "[]" || trimmed == "{}" {
+ continue
+ }
+ items = append(items, r)
+ }
+ if len(items) == 0 {
+ return nil, DataProvenance{}, fmt.Errorf("no local data for %q. Run 'human-goat-pp-cli sync' first", resourceType)
+ }
+ // Marshal []json.RawMessage into a single JSON array
+ data, err := json.Marshal(items)
+ if err != nil {
+ return nil, DataProvenance{}, fmt.Errorf("marshaling local data: %w", err)
+ }
+ return data, prov, nil
+ }
+
+ // Get by ID — extract the last path segment as the ID
+ parts := strings.Split(strings.TrimRight(path, "/"), "/")
+ id := parts[len(parts)-1]
+
+ item, err := db.Get(resourceType, id)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, DataProvenance{}, fmt.Errorf("resource %q with ID %q not found in local store. Run 'human-goat-pp-cli sync' first", resourceType, id)
+ }
+ return nil, DataProvenance{}, fmt.Errorf("querying local store: %w", err)
+ }
+ return item, prov, nil
+}
+
+// Ensure time import is used (compilation guard).
+var _ = time.Now
diff --git a/library/productivity/human-goat/internal/cli/deliver.go b/library/productivity/human-goat/internal/cli/deliver.go
new file mode 100644
index 0000000000..73abdf28ac
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/deliver.go
@@ -0,0 +1,114 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// DeliverSink describes where command output should be routed when
+// --deliver is set. Parsed from the sink specifier "scheme:target".
+type DeliverSink struct {
+ Scheme string
+ Target string
+}
+
+// ParseDeliverSink parses a --deliver value. Supported schemes:
+//
+// stdout -> default, no redirection
+// file: -> write output atomically to
+// webhook: -> POST output body to
+//
+// Returns an error for unknown schemes with a message naming the
+// supported set, so agents see a structured refusal rather than a
+// silent misroute.
+func ParseDeliverSink(spec string) (DeliverSink, error) {
+ if spec == "" || spec == "stdout" {
+ return DeliverSink{Scheme: "stdout"}, nil
+ }
+ idx := strings.Index(spec, ":")
+ if idx == -1 {
+ return DeliverSink{}, fmt.Errorf("unknown --deliver sink %q: expected scheme:target (supported: stdout, file:, webhook:)", spec)
+ }
+ scheme := spec[:idx]
+ target := spec[idx+1:]
+ switch scheme {
+ case "file":
+ if target == "" {
+ return DeliverSink{}, fmt.Errorf("--deliver file: requires a path")
+ }
+ case "webhook":
+ if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
+ return DeliverSink{}, fmt.Errorf("--deliver webhook: requires an http:// or https:// URL, got %q", target)
+ }
+ default:
+ return DeliverSink{}, fmt.Errorf("unknown --deliver scheme %q (supported: stdout, file, webhook)", scheme)
+ }
+ return DeliverSink{Scheme: scheme, Target: target}, nil
+}
+
+// Deliver routes a captured output buffer to the configured sink. stdout
+// is a no-op because the buffer has already been streamed to stdout via
+// the MultiWriter set up in root.go.
+func Deliver(sink DeliverSink, body []byte, compact bool) error {
+ switch sink.Scheme {
+ case "", "stdout":
+ return nil
+ case "file":
+ return deliverFile(sink.Target, body)
+ case "webhook":
+ return deliverWebhook(sink.Target, body, compact)
+ default:
+ return fmt.Errorf("unsupported deliver sink %q", sink.Scheme)
+ }
+}
+
+func deliverFile(path string, body []byte) error {
+ // Atomic write: tmp + rename. Protects agents from seeing a partial
+ // file if the process is interrupted mid-write.
+ dir := filepath.Dir(path)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("creating deliver dir: %w", err)
+ }
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, body, 0o600); err != nil {
+ return fmt.Errorf("writing deliver tmp: %w", err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ return fmt.Errorf("replacing deliver file: %w", err)
+ }
+ return nil
+}
+
+func deliverWebhook(url string, body []byte, compact bool) error {
+ contentType := "application/json"
+ if compact {
+ contentType = "application/x-ndjson"
+ }
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("building webhook request: %w", err)
+ }
+ req.Header.Set("Content-Type", contentType)
+ req.Header.Set("User-Agent", "human-goat-pp-cli/deliver")
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("posting to webhook: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("webhook returned %s", resp.Status)
+ }
+ return nil
+}
diff --git a/library/productivity/human-goat/internal/cli/dispatch.go b/library/productivity/human-goat/internal/cli/dispatch.go
new file mode 100644
index 0000000000..e145662cf3
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/dispatch.go
@@ -0,0 +1,154 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// pp:data-source live
+// Novel command scaffold. Implement the RunE body before shipping.
+// generate --force preserves implemented bodies; untouched TODO scaffolds may refresh.
+
+package cli
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/magic"
+)
+
+func newNovelDispatchCmd(flags *rootFlags) *cobra.Command {
+ var flagVia string
+ var flagExecute bool
+
+ cmd := &cobra.Command{
+ Use: "dispatch ",
+ Short: "Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override.",
+ Example: ` human-goat-pp-cli dispatch "call the dentist and reschedule my cleaning"
+ human-goat-pp-cli dispatch "assemble an ikea dresser" --via taskrabbit`,
+ Annotations: map[string]string{"pp:no-error-path-probe": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && !commandHasChangedFlags(cmd) {
+ return cmd.Help()
+ }
+ task := strings.TrimSpace(strings.Join(args, " "))
+ if task == "" {
+ return usageErr(fmt.Errorf("missing task"))
+ }
+
+ route, reason, err := dispatchRoute(task, flagVia)
+ if err != nil {
+ return usageErr(err)
+ }
+ decision := dispatchDecision{
+ Task: task,
+ Route: route,
+ Reason: reason,
+ SuggestedCommand: dispatchSuggestedCommand(route, task),
+ }
+
+ if dryRunOK(flags) || !flagExecute {
+ return printDispatchDecision(cmd, flags, decision)
+ }
+
+ if route == "taskrabbit" {
+ fmt.Fprintf(cmd.OutOrStdout(), "taskrabbit route requires `hire` (autonomous checkout is gated); run: %s\n", decision.SuggestedCommand)
+ return nil
+ }
+ if cliutil.IsVerifyEnv() {
+ fmt.Fprintf(cmd.OutOrStdout(), "would dispatch Magic task: %s\n", task)
+ return nil
+ }
+
+ ctx, cancel := boundCtx(cmd.Context(), flags)
+ defer cancel()
+
+ client, err := magic.NewClient()
+ if err != nil {
+ return fmt.Errorf("initialize Magic client: %w", err)
+ }
+ req, err := client.Send(ctx, magic.SendParams{
+ Title: "Errand",
+ Instructions: task,
+ Objective: "Complete the errand and report back",
+ })
+ if err != nil {
+ return fmt.Errorf("dispatch Magic task: %w", err)
+ }
+ persistMagicRequest(ctx, req)
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), magicRequestSummary(req), flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "request id: %s\n", req.ID)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&flagVia, "via", "", "Route override: magic or taskrabbit")
+ cmd.Flags().BoolVar(&flagExecute, "execute", false, "Create a Magic request for Magic routes; TaskRabbit checkout remains gated")
+ return cmd
+}
+
+type dispatchDecision struct {
+ Task string `json:"task"`
+ Route string `json:"route"`
+ Reason string `json:"reason"`
+ SuggestedCommand string `json:"suggested_command"`
+}
+
+func dispatchRoute(task, via string) (string, string, error) {
+ via = strings.ToLower(strings.TrimSpace(via))
+ switch via {
+ case "magic", "taskrabbit":
+ return via, "forced by --via", nil
+ case "":
+ default:
+ return "", "", fmt.Errorf("--via must be magic or taskrabbit")
+ }
+
+ needle := strings.ToLower(task)
+ remote := containsAnyPhrase(needle, []string{
+ "call", "phone", "dial", "ask", "lookup", "look up", "research", "find out", "hours",
+ "order online", "book online", "reservation", "schedule a", "email", "fill out", "data entry",
+ })
+ inPerson := containsAnyPhrase(needle, []string{
+ "move", "moving", "haul", "assemble", "assembly", "mount", "mounting", "install", "clean",
+ "cleaning", "yard", "furniture", "tv", "ikea", "pack", "unpack", "lift", "delivery in person",
+ })
+
+ switch {
+ case remote && !inPerson:
+ return "magic", "matched remote-doable task shape", nil
+ case inPerson && !remote:
+ return "taskrabbit", "matched in-person task shape", nil
+ default:
+ return "magic", "ambiguous; defaulting to magic (override with --via)", nil
+ }
+}
+
+func containsAnyPhrase(s string, phrases []string) bool {
+ for _, phrase := range phrases {
+ if strings.Contains(s, phrase) {
+ return true
+ }
+ }
+ return false
+}
+
+func dispatchSuggestedCommand(route, task string) string {
+ switch route {
+ case "taskrabbit":
+ return fmt.Sprintf("human-goat-pp-cli hire %q --on --min-rating 4.8 --lat --lng ", task)
+ default:
+ return fmt.Sprintf("human-goat-pp-cli send --title %q --instructions %q --objective %q", "Errand", task, "Complete the errand and report back")
+ }
+}
+
+func printDispatchDecision(cmd *cobra.Command, flags *rootFlags, decision dispatchDecision) error {
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), decision, flags)
+ }
+ w := cmd.OutOrStdout()
+ fmt.Fprintf(w, "task: %s\n", decision.Task)
+ fmt.Fprintf(w, "route: %s\n", decision.Route)
+ fmt.Fprintf(w, "reason: %s\n", decision.Reason)
+ fmt.Fprintf(w, "run: %s\n", decision.SuggestedCommand)
+ return nil
+}
diff --git a/library/productivity/human-goat/internal/cli/dispatch_test.go b/library/productivity/human-goat/internal/cli/dispatch_test.go
new file mode 100644
index 0000000000..3e25e013a8
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/dispatch_test.go
@@ -0,0 +1,32 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// cli-printing-press: novel-scaffold-test
+// Novel command scaffold tests. Keep the wiring smoke test and add behavior cases as needed.
+
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// TestNovelDispatchHelpWires smoke-tests that the dispatch command
+// resolves at runtime and renders useful --help output. Catches wiring
+// regressions (missing AddCommand, panicking RunE on --help, etc.) before
+// review. Keep this smoke test when adding behavior-specific cases.
+func TestNovelDispatchHelpWires(t *testing.T) {
+ cmd := RootCmd()
+ cmd.SetArgs([]string{"dispatch", "--help"})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("dispatch --help error = %v (novel command not wired correctly?)", err)
+ }
+ help := out.String()
+ for _, want := range []string{"Usage:", "dispatch"} {
+ if !strings.Contains(help, want) {
+ t.Fatalf("dispatch --help missing %q in output:\n%s", want, help)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/doctor.go b/library/productivity/human-goat/internal/cli/doctor.go
new file mode 100644
index 0000000000..9085fceaf3
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/doctor.go
@@ -0,0 +1,739 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/config"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/magic"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+ "github.com/spf13/cobra"
+)
+
+// looksLikeDoctorInterstitial reports whether the response body matches a known
+// bot-detection challenge page (Cloudflare, Akamai, Vercel, AWS WAF, DataDome,
+// PerimeterX). Only fires on the doctor probe — used to distinguish "transport
+// reached the wall" from "transport failed entirely." Returns the vendor name
+// when matched, or empty string when no match.
+//
+// Markers are anchored to or vendor-specific strings to avoid
+// false-positives on benign content. For example, a recipe titled "Just A
+// Moment of Pause Cookies" must NOT match the Cloudflare challenge marker;
+// only "just a moment" (the actual interstitial title) does.
+func looksLikeDoctorInterstitial(body []byte) string {
+ if len(body) == 0 {
+ return ""
+ }
+ limit := len(body)
+ if limit > 8192 {
+ limit = 8192
+ }
+ prefix := strings.ToLower(string(body[:limit]))
+ if !strings.Contains(prefix, "; bodies without
+ // one are body-only API responses, not challenge pages.
+ return ""
+ }
+ switch {
+ case strings.Contains(prefix, "just a moment") || // CF JS challenge
+ strings.Contains(prefix, "challenges.cloudflare.com") || // CF Turnstile
+ (strings.Contains(prefix, "attention required") && strings.Contains(prefix, "cloudflare")):
+ return "Cloudflare"
+ case strings.Contains(prefix, "akamai") && (strings.Contains(prefix, "request unsuccessful") || strings.Contains(prefix, "access denied")):
+ return "Akamai"
+ case strings.Contains(prefix, "x-vercel-mitigated") || strings.Contains(prefix, "x-vercel-challenge-token") ||
+ (strings.Contains(prefix, "vercel") && strings.Contains(prefix, "challenge")):
+ return "Vercel"
+ case strings.Contains(prefix, "request blocked") && strings.Contains(prefix, "aws waf"):
+ return "AWS WAF"
+ case strings.Contains(prefix, "datadome") && (strings.Contains(prefix, "blocked") || strings.Contains(prefix, "captcha") || strings.Contains(prefix, "challenge")):
+ return "DataDome"
+ case strings.Contains(prefix, "perimeterx") || strings.Contains(prefix, "px-captcha"):
+ return "PerimeterX"
+ }
+ return ""
+}
+
+// suggestReadCommand walks the Cobra tree to find an endpoint-mirror command
+// an operator can run to confirm credentials work end-to-end. Picks the
+// first leaf that (a) carries the `pp:endpoint` annotation, so it actually
+// dials the API rather than reading a local file like `feedback list` or
+// `profile list`; (b) has a list/get verb; and (c) takes no positional
+// arguments, so the suggestion is copy-paste runnable. Returns the dotted
+// command path (e.g. "issues list") or "" when no such command exists —
+// common in mutation-only CLIs and in CLIs where every read command has
+// required positional arguments.
+func suggestReadCommand(root *cobra.Command) string {
+ if root == nil {
+ return ""
+ }
+ var found string
+ var walk func(*cobra.Command, []string)
+ walk = func(cmd *cobra.Command, path []string) {
+ if found != "" {
+ return
+ }
+ for _, child := range cmd.Commands() {
+ childPath := append(append([]string{}, path...), child.Name())
+ if isSuggestableReadLeaf(child) {
+ found = strings.Join(childPath, " ")
+ return
+ }
+ // Recurse even into Hidden parents: printed CLIs mark raw
+ // resource parents Hidden to keep --help curated, but their
+ // endpoint leaves remain runnable (` projects list`
+ // works). Skipping hidden subtrees would make this return ""
+ // in nearly every CLI. isSuggestableReadLeaf still rejects a
+ // leaf that is itself Hidden.
+ walk(child, childPath)
+ if found != "" {
+ return
+ }
+ }
+ }
+ walk(root, nil)
+ return found
+}
+
+func isSuggestableReadLeaf(cmd *cobra.Command) bool {
+ if cmd == nil || cmd.Hidden || cmd.HasSubCommands() || !cmd.Runnable() {
+ return false
+ }
+ // Only endpoint-mirror commands count; framework commands like
+ // `feedback list` and `profile list` read local files and would
+ // recreate the false-confidence failure mode the suggestion is
+ // supposed to avoid.
+ if cmd.Annotations["pp:endpoint"] == "" {
+ return false
+ }
+ verb := strings.ToLower(strings.SplitN(cmd.Use, " ", 2)[0])
+ if verb != "list" && verb != "get" {
+ return false
+ }
+ // Endpoint commands with positional path params advertise them in
+ // Use as `` (required) or `[id]` (optional). The runtime body
+ // rejects empty args by printing help, so suggesting one would not
+ // actually exercise the token — reject before the Args probe below.
+ if strings.ContainsAny(cmd.Use, "<[") {
+ return false
+ }
+ // Probe the Args validator with an empty positional-arg list. A nil
+ // validator accepts anything (including zero args); a non-nil validator
+ // that returns nil for [] accepts zero args. Either qualifies — the
+ // suggestion ` list` is then a complete command.
+ if cmd.Args == nil {
+ return true
+ }
+ return cmd.Args(cmd, []string{}) == nil
+}
+
+func newDoctorCmd(flags *rootFlags) *cobra.Command {
+ var failOn string
+ cmd := &cobra.Command{
+ Use: "doctor",
+ Short: "Check CLI health",
+ Example: ` human-goat-pp-cli doctor
+ human-goat-pp-cli doctor --json
+ human-goat-pp-cli doctor --fail-on warn
+ human-goat-pp-cli doctor --fail-on stale`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ report := map[string]any{}
+ pathsReport := collectPathsReport()
+ report["paths"] = pathsReport
+ if warning := pathsWarning(pathsReport); warning != "" {
+ report["paths_warning"] = warning
+ }
+
+ // Check config
+ cfg, err := config.Load(flags.configPath)
+ if err != nil {
+ report["config"] = fmt.Sprintf("error: %s", err)
+ } else {
+ report["config"] = "ok"
+ report["config_path"] = cfg.Path
+ report["base_url"] = cfg.BaseURL
+ collectCredentialsLocationReport(report, cfg)
+ }
+
+ // Check auth
+ authConfigured := false
+ if cfg != nil {
+ header := cfg.AuthHeader()
+ if header == "" {
+ report["auth"] = "not configured"
+ report["auth_hint"] = "human-goat-pp-cli auth login --chrome"
+ } else {
+ authConfigured = true
+ report["auth"] = "configured (browser session)"
+ report["auth_source"] = cfg.AuthSource
+ report["auth_domain"] = ".taskrabbit.com"
+ }
+ }
+ // Check cookie tool availability
+ cookieToolFound := false
+ for _, check := range [][]string{
+ {"python3", "-c", "import pycookiecheat"},
+ {"cookies", "--help"},
+ {"cookie-scoop", "--help"},
+ } {
+ if err := exec.Command(check[0], check[1:]...).Run(); err == nil {
+ cookieToolFound = true
+ report["cookie_tool"] = check[0]
+ break
+ }
+ }
+ if !cookieToolFound {
+ report["cookie_tool"] = "not found (install: pip install pycookiecheat)"
+ }
+ _ = authConfigured
+
+ // Check auth environment variables
+
+ // Check API connectivity and validate credentials.
+ //
+ // The doctor uses the same client every other command uses --
+ // flags.newClient() returns a *client.Client wrapping whatever
+ // transport the spec declared (Surf for browser-chrome, stdlib
+ // for standard). A separate stdlib http.Client would silently
+ // bypass that choice and report false negatives against
+ // Cloudflare-fronted, Akamai-fronted, or otherwise bot-detected
+ // sites. By going through flags.newClient(), the doctor's
+ // reachability verdict matches what real commands experience.
+ if cfg != nil && cfg.BaseURL != "" {
+ c, clientErr := flags.newClient()
+ if clientErr != nil {
+ report["api"] = fmt.Sprintf("client init error: %s", clientErr)
+ } else {
+ // Step 1: Basic reachability via the configured transport.
+ healthPath := "/api/v3/web-client/bootstrap.json"
+ if !strings.HasPrefix(healthPath, "/") {
+ healthPath = "/" + healthPath
+ }
+ reachBody, reachErr := c.Get(cmd.Context(), healthPath, nil)
+ var reachAPIErr *client.APIError
+ switch {
+ case reachErr == nil:
+ // 2xx response — clearly reachable. Still inspect the
+ // body for a known interstitial; some bot walls return
+ // 200 with a JS challenge page.
+ if vendor := looksLikeDoctorInterstitial(reachBody); vendor != "" {
+ report["api"] = fmt.Sprintf("blocked by %s interstitial — the configured transport reached the wall. Try a different network, wait for the IP-level rate limit to clear, or check that the browser-chrome transport is bound correctly.", vendor)
+ } else {
+ report["api"] = "reachable"
+ }
+ case errors.As(reachErr, &reachAPIErr):
+ // Non-2xx from the server. The network reached, the
+ // server responded — that's "reachable" for our
+ // purposes. Inspect the response body for a known
+ // interstitial first; otherwise note the status.
+ status := reachAPIErr.StatusCode
+ if vendor := looksLikeDoctorInterstitial([]byte(reachAPIErr.Body)); vendor != "" {
+ report["api"] = fmt.Sprintf("blocked by %s interstitial (HTTP %d) — the configured transport reached the wall.", vendor, status)
+ } else {
+ report["api"] = fmt.Sprintf("reachable (HTTP %d at /)", status)
+ }
+ default:
+ // Network-level failure: DNS, connection refused, TLS,
+ // transport init, etc. The transport itself didn't
+ // connect.
+ report["api"] = fmt.Sprintf("unreachable: %s", reachErr)
+ }
+
+ // Step 2: Validate credentials with an authenticated probe.
+ authHeader := cfg.AuthHeader()
+ if authHeader == "" {
+ // No auth configured — skip credential validation
+ } else if reachErr != nil && !errors.As(reachErr, &reachAPIErr) {
+ report["credentials"] = "skipped (API unreachable)"
+ } else {
+ suggestion := suggestReadCommand(cmd.Root())
+ if suggestion != "" {
+ report["credentials"] = fmt.Sprintf("present, not verified. Run `%s %s` to confirm the token works end-to-end.", "human-goat-pp-cli", suggestion)
+ } else {
+ report["credentials"] = "present, not verified. Run any read command to confirm the token works end-to-end."
+ }
+ }
+ }
+ } else if cfg != nil && cfg.BaseURL == "" {
+ report["api"] = "not configured (set base_url in config file)"
+ }
+ if key, err := magic.ResolveKey(); err != nil {
+ report["magic"] = "WARN no API key ($MAGIC_API_KEY or ~/.magic/api_key)"
+ } else {
+ report["magic"] = "key present (" + maskMagicKey(key) + ")"
+ if _, err := magic.NewClient(); err != nil {
+ report["magic_reachability"] = "WARN backend config invalid: " + err.Error()
+ } else {
+ report["magic_reachability"] = "INFO not probed; doctor does not dispatch Magic tasks"
+ }
+ }
+ // Cache health: only reported when this CLI has generated sync.
+ // Surfaces rows + last_synced_at per resource, schema version,
+ // and a fresh/stale/unknown verdict so agents can introspect
+ // whether to trust the cached data before issuing queries.
+ report["cache"] = collectCacheReport(cmd.Context(), "")
+
+ // Verify mode state. Surfaced so an operator who unintentionally
+ // inherits PRINTING_PRESS_VERIFY=1 (parent shell, CI runner, container
+ // image) detects the foot-gun without inspecting a response body.
+ // Pairs with the synthetic envelope's verify_noop / reason literals
+ // as a second diagnosis anchor.
+ if cliutil.IsVerifyEnv() {
+ if cliutil.IsVerifyLiveHTTPEnv() {
+ report["verify_mode"] = "INFO ACTIVE — live HTTP opt-in (mutating verbs dial out)"
+ } else {
+ report["verify_mode"] = "INFO ACTIVE — mutating HTTP verbs short-circuit (PRINTING_PRESS_VERIFY=1; no network calls for DELETE/POST/PUT/PATCH)"
+ }
+ } else {
+ report["verify_mode"] = "normal operation"
+ }
+
+ report["version"] = version
+
+ if flags.asJSON {
+ if err := printJSONFiltered(cmd.OutOrStdout(), report, flags); err != nil {
+ return err
+ }
+ return doctorExitForFailOn(failOn, report)
+ }
+
+ // Human-readable output with color
+ w := cmd.OutOrStdout()
+ checkKeys := []struct{ key, label string }{
+ {"config", "Config"},
+ {"auth", "Auth"},
+ {"env_vars", "Env Vars"},
+ {"verify_mode", "Verify Mode"},
+ {"paths_warning", "Paths"},
+ {"credentials_location_warning", "Credentials Storage"},
+ {"api", "API"},
+ {"credentials", "Credentials"},
+ {"magic", "Magic"},
+ {"magic_reachability", "Magic Reachability"},
+ }
+ for _, ck := range checkKeys {
+ v, ok := report[ck.key]
+ if !ok {
+ continue
+ }
+ s := fmt.Sprintf("%v", v)
+ indicator := green("OK")
+ switch {
+ case strings.HasPrefix(s, "WARN"):
+ indicator = yellow("WARN")
+ case strings.HasPrefix(s, "INFO"):
+ indicator = yellow("INFO")
+ case strings.HasPrefix(s, "ERROR"):
+ indicator = red("FAIL")
+ case strings.HasPrefix(s, "optional"):
+ // Optional-auth CLI with no key set — informational, not a failure.
+ indicator = yellow("INFO")
+ case strings.Contains(s, "scope-limited"):
+ indicator = yellow("WARN")
+ case strings.Contains(s, "not verified"):
+ // "present, not verified" — credentials are loaded but no
+ // probe ran. Informational, not a warning; a clean config
+ // shouldn't render yellow WARN in CI dashboards.
+ indicator = yellow("INFO")
+ case strings.Contains(s, "error") || strings.Contains(s, "not configured") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing"):
+ indicator = red("FAIL")
+ case s == "not required":
+ // Public APIs: no auth needed is a healthy state, not a warning.
+ indicator = green("OK")
+ case strings.Contains(s, "not ") || strings.Contains(s, "skipped") || strings.Contains(s, "inferred"):
+ indicator = yellow("WARN")
+ }
+ display := s
+ if ck.key == "magic" && strings.HasPrefix(display, "WARN ") {
+ display = strings.TrimPrefix(display, "WARN ")
+ }
+ fmt.Fprintf(w, " %s %s: %s\n", indicator, ck.label, display)
+ }
+ // Print info keys without status indicator
+ for _, key := range []string{"config_path", "base_url", "auth_source", "credentials_location", "version"} {
+ if v, ok := report[key]; ok {
+ fmt.Fprintf(w, " %s: %v\n", key, v)
+ }
+ }
+ // Print auth setup hints (indented under Auth line)
+ // Cache section: render after the primary health block so it
+ // sits next to version info, mirroring the JSON report layout.
+ if cacheAny, ok := report["cache"]; ok {
+ if cacheRep, ok := cacheAny.(map[string]any); ok {
+ renderCacheReport(w, cacheRep)
+ }
+ }
+ if pathsAny, ok := report["paths"]; ok {
+ if pathsRep, ok := pathsAny.(map[string]any); ok {
+ renderPathsReport(w, pathsRep)
+ }
+ }
+ return doctorExitForFailOn(failOn, report)
+ },
+ }
+ cmd.Flags().StringVar(&failOn, "fail-on", "", "Exit non-zero for selected health gates. stale: cache freshness plus errors; warn: credential/path warnings plus errors; error: errors only. Default is never.")
+ return cmd
+}
+
+func collectPathsReport() map[string]any {
+ report := map[string]any{}
+ resolutions, err := cliutil.AllPathResolutions()
+ if err != nil {
+ report["status"] = "error"
+ report["detail"] = err.Error()
+ return report
+ }
+ report["status"] = "ok"
+ ignoredSeen := map[string]bool{}
+ var ignored []map[string]string
+ var notes []string
+ for _, resolution := range resolutions {
+ report[resolution.KindName] = map[string]any{
+ "dir": resolution.Dir,
+ "rung": resolution.Rung,
+ "source": resolution.Source,
+ }
+ for _, skipped := range resolution.IgnoredOverrides {
+ key := skipped.Name + "\x00" + skipped.Value
+ if ignoredSeen[key] {
+ continue
+ }
+ ignoredSeen[key] = true
+ ignored = append(ignored, map[string]string{
+ "name": skipped.Name,
+ "value": skipped.Value,
+ })
+ }
+ if cliutil.HomeOverrideActive() && resolution.Rung == "per-kind-env" && (resolution.Kind == cliutil.PathKindData || resolution.Kind == cliutil.PathKindConfig) {
+ notes = append(notes, fmt.Sprintf("--home shadowed for %s by %s", resolution.KindName, resolution.Source))
+ }
+ }
+ if len(ignored) > 0 {
+ report["skipped_relative_overrides"] = ignored
+ }
+ if len(notes) > 0 {
+ report["notes"] = notes
+ }
+ return report
+}
+
+func pathsWarning(report map[string]any) string {
+ if report == nil {
+ return ""
+ }
+ var parts []string
+ if raw, ok := report["skipped_relative_overrides"].([]map[string]string); ok && len(raw) > 0 {
+ names := make([]string, 0, len(raw))
+ for _, entry := range raw {
+ names = append(names, entry["name"])
+ }
+ parts = append(parts, "relative override skipped: "+strings.Join(names, ", "))
+ }
+ if raw, ok := report["notes"].([]string); ok && len(raw) > 0 {
+ parts = append(parts, "home override shadowed")
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return "WARN paths: " + strings.Join(parts, "; ")
+}
+
+func renderPathsReport(w io.Writer, rep map[string]any) {
+ fmt.Fprintf(w, " Paths:\n")
+ for _, kind := range []string{"config", "data", "state", "cache"} {
+ entry, ok := rep[kind].(map[string]any)
+ if !ok {
+ continue
+ }
+ fmt.Fprintf(w, " %s: %v (%v)\n", kind, entry["dir"], entry["source"])
+ }
+ if raw, ok := rep["skipped_relative_overrides"].([]map[string]string); ok && len(raw) > 0 {
+ fmt.Fprintf(w, " skipped_relative_overrides:\n")
+ for _, entry := range raw {
+ fmt.Fprintf(w, " %s=%q\n", entry["name"], entry["value"])
+ }
+ }
+ if raw, ok := rep["notes"].([]string); ok && len(raw) > 0 {
+ fmt.Fprintf(w, " notes:\n")
+ for _, note := range raw {
+ fmt.Fprintf(w, " %s\n", note)
+ }
+ }
+}
+
+func maskMagicKey(key string) string {
+ key = strings.TrimSpace(key)
+ if len(key) < 4 {
+ return "…****"
+ }
+ return "…" + key[len(key)-4:]
+}
+
+func collectCredentialsLocationReport(report map[string]any, cfg *config.Config) {
+ if cfg == nil {
+ return
+ }
+ if cfg.CredentialSource != "" {
+ report["credentials_location"] = cfg.CredentialSource
+ } else {
+ report["credentials_location"] = "none"
+ }
+ if cfg.AgentcookieManagedByExternalStore() {
+ return
+ }
+
+ locations := []string{}
+ credsPresent, err := cliutil.CredentialsFileHasValues()
+ if err == nil && credsPresent {
+ locations = append(locations, "credentials file")
+ }
+ legacySecretsElsewhere := ""
+ for _, path := range legacyCredentialProbePaths(cfg) {
+ ok, err := config.FileHasCredentialFields(path)
+ if err == nil && ok {
+ locations = append(locations, path)
+ if path != cfg.Path {
+ legacySecretsElsewhere = path
+ }
+ }
+ }
+ if len(locations) > 0 {
+ report["credentials_locations"] = locations
+ }
+ if credsPresent && len(locations) > 1 {
+ if legacySecretsElsewhere != "" {
+ report["credentials_location_warning"] = "WARN credentials stored in more than one location; legacy secrets remain at " + legacySecretsElsewhere + "; run auth set-token or auth logout to consolidate and remove legacy secrets"
+ } else {
+ report["credentials_location_warning"] = "WARN credentials stored in more than one location; current reads use credentials file; run auth set-token or auth logout to consolidate"
+ }
+ }
+}
+
+func legacyCredentialProbePaths(cfg *config.Config) []string {
+ seen := map[string]bool{}
+ var paths []string
+ add := func(path string) {
+ if path == "" || seen[path] {
+ return
+ }
+ seen[path] = true
+ paths = append(paths, path)
+ }
+ if cfg != nil && cfg.Path != "" {
+ // Probe only the active config; a same-dir standard-named file may
+ // belong to an unrelated CLI sharing that directory.
+ add(cfg.Path)
+ }
+ if legacyPath, err := config.LegacyConfigPath(); err == nil {
+ add(legacyPath)
+ }
+ return paths
+}
+
+// doctorExitForFailOn returns a non-nil error when the report's worst
+// status meets the --fail-on gate. "error" trips on failing sections, "warn"
+// trips on deliberate WARN sections plus errors, and "stale" trips on cache
+// freshness plus errors. The default empty string means never fail on status.
+func doctorExitForFailOn(failOn string, report map[string]any) error {
+ if failOn == "" {
+ return nil
+ }
+ worstError := false
+ worstWarn := false
+ worstStale := false
+ for _, v := range report {
+ s, ok := v.(string)
+ if ok {
+ if strings.Contains(s, "error") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") || strings.Contains(s, "missing") {
+ worstError = true
+ }
+ if strings.HasPrefix(s, "WARN") {
+ worstWarn = true
+ }
+ }
+ if m, ok := v.(map[string]any); ok {
+ if st, _ := m["status"].(string); st == "error" {
+ worstError = true
+ } else if st == "warn" {
+ worstWarn = true
+ } else if st == "stale" {
+ worstStale = true
+ }
+ }
+ }
+ switch failOn {
+ case "error":
+ if worstError {
+ return fmt.Errorf("doctor: --fail-on=error triggered")
+ }
+ case "warn":
+ if worstError || worstWarn {
+ return fmt.Errorf("doctor: --fail-on=warn triggered")
+ }
+ case "stale":
+ if worstError || worstStale {
+ return fmt.Errorf("doctor: --fail-on=stale triggered")
+ }
+ default:
+ return fmt.Errorf("doctor: unknown --fail-on value %q (valid: stale, warn, error)", failOn)
+ }
+ return nil
+}
+
+// collectCacheReport opens the local store, reads per-resource sync state,
+// and returns a map summarising cache health. Never panics on missing DB
+// or open failure; returns a map with status=unknown or status=error so the
+// caller can render and agents can interpret.
+//
+// staleAfterSpec is the CLI's configured threshold (e.g. "6h"); empty means
+// use the runtime default. The default is deliberately conservative (6h)
+// because the alternative is no freshness story at all.
+func collectCacheReport(ctx context.Context, staleAfterSpec string) map[string]any {
+ report := map[string]any{}
+ dbPath := defaultDBPath("human-goat-pp-cli")
+ report["db_path"] = dbPath
+
+ fi, err := os.Stat(dbPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ report["status"] = "unknown"
+ report["hint"] = "Database not created yet; run 'human-goat-pp-cli sync' to hydrate."
+ return report
+ }
+ report["status"] = "error"
+ report["error"] = err.Error()
+ return report
+ }
+ report["db_bytes"] = fi.Size()
+
+ s, err := store.OpenWithContext(ctx, dbPath)
+ if err != nil {
+ report["status"] = "error"
+ report["error"] = err.Error()
+ return report
+ }
+ defer s.Close()
+
+ if v, verr := s.SchemaVersion(); verr == nil {
+ report["schema_version"] = v
+ }
+
+ staleAfter := 6 * time.Hour
+ if staleAfterSpec != "" {
+ if d, derr := time.ParseDuration(staleAfterSpec); derr == nil {
+ staleAfter = d
+ }
+ }
+
+ rows, qerr := s.DB().Query(`SELECT resource_type, COALESCE(total_count, 0), last_synced_at FROM sync_state ORDER BY resource_type`)
+ if qerr != nil {
+ // sync_state may not exist on a fresh DB that has migrated but not
+ // yet had any sync runs — treat as unknown rather than error.
+ report["status"] = "unknown"
+ report["hint"] = "No sync state recorded; run 'human-goat-pp-cli sync' to populate."
+ return report
+ }
+ defer rows.Close()
+
+ var resources []map[string]any
+ fresh := true
+ haveAny := false
+ oldest := time.Duration(0)
+ for rows.Next() {
+ var rtype string
+ var count int64
+ var lastSynced sql.NullTime
+ if err := rows.Scan(&rtype, &count, &lastSynced); err != nil {
+ continue
+ }
+ r := map[string]any{"type": rtype, "rows": count}
+ if lastSynced.Valid {
+ haveAny = true
+ r["last_synced_at"] = lastSynced.Time.UTC().Format(time.RFC3339)
+ age := time.Since(lastSynced.Time)
+ r["staleness"] = age.Round(time.Minute).String()
+ if age > staleAfter {
+ fresh = false
+ }
+ if age > oldest {
+ oldest = age
+ }
+ } else {
+ r["staleness"] = "never"
+ fresh = false
+ }
+ resources = append(resources, r)
+ }
+ report["resources"] = resources
+ report["stale_after"] = staleAfter.String()
+
+ switch {
+ case !haveAny && len(resources) == 0:
+ report["status"] = "empty"
+ report["hint"] = "Cache is empty; run 'human-goat-pp-cli sync' to hydrate."
+ case fresh:
+ report["status"] = "fresh"
+ default:
+ report["status"] = "stale"
+ report["oldest_age"] = oldest.Round(time.Minute).String()
+ report["hint"] = "Some resources are older than stale_after; run 'human-goat-pp-cli sync' to refresh."
+ }
+ return report
+}
+
+func renderCacheReport(w io.Writer, rep map[string]any) {
+ status, _ := rep["status"].(string)
+ indicator := green("OK")
+ switch status {
+ case "stale":
+ indicator = yellow("WARN")
+ case "error":
+ indicator = red("FAIL")
+ case "unknown":
+ indicator = yellow("INFO")
+ case "empty":
+ indicator = yellow("INFO")
+ }
+ fmt.Fprintf(w, " %s Cache: %s\n", indicator, status)
+ if v, ok := rep["db_path"]; ok {
+ fmt.Fprintf(w, " db_path: %v\n", v)
+ }
+ if v, ok := rep["schema_version"]; ok {
+ fmt.Fprintf(w, " schema_version: %v\n", v)
+ }
+ if v, ok := rep["db_bytes"]; ok {
+ fmt.Fprintf(w, " db_bytes: %v\n", v)
+ }
+ if v, ok := rep["stale_after"]; ok {
+ fmt.Fprintf(w, " stale_after: %v\n", v)
+ }
+ if v, ok := rep["oldest_age"]; ok {
+ fmt.Fprintf(w, " oldest_age: %v\n", v)
+ }
+ if resourcesAny, ok := rep["resources"]; ok {
+ if resources, ok := resourcesAny.([]map[string]any); ok && len(resources) > 0 {
+ fmt.Fprintf(w, " resources:\n")
+ for _, r := range resources {
+ rtype, _ := r["type"].(string)
+ rows := r["rows"]
+ staleness, _ := r["staleness"].(string)
+ fmt.Fprintf(w, " - %s: %v rows, %s\n", rtype, rows, staleness)
+ }
+ }
+ }
+ if hint, ok := rep["hint"]; ok {
+ fmt.Fprintf(w, " hint: %v\n", hint)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/feedback.go b/library/productivity/human-goat/internal/cli/feedback.go
new file mode 100644
index 0000000000..0553845ae2
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/feedback.go
@@ -0,0 +1,228 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/spf13/cobra"
+)
+
+// FeedbackEntry is one line in the local feedback ledger. Every run of
+// the feedback command appends one entry; upstream POST is a separate,
+// optional step.
+type FeedbackEntry struct {
+ Text string `json:"text"`
+ CLI string `json:"cli"`
+ Version string `json:"version"`
+ AgentID string `json:"agent_id,omitempty"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+const feedbackMaxTextLen = 4096
+
+func feedbackFilePath() (string, error) {
+ dir, err := cliutil.DataDir()
+ if err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("creating feedback data dir: %w", err)
+ }
+ return filepath.Join(dir, "feedback.jsonl"), nil
+}
+
+// FeedbackEndpointConfigured reports whether an upstream feedback URL
+// is available. Surfaced via agent-context so introspecting agents know
+// whether their feedback will ship upstream.
+func FeedbackEndpointConfigured() bool {
+ return os.Getenv("HUMAN_GOAT_FEEDBACK_ENDPOINT") != ""
+}
+
+func feedbackEndpoint() string {
+ return os.Getenv("HUMAN_GOAT_FEEDBACK_ENDPOINT")
+}
+
+func feedbackAutoSend() bool {
+ v := strings.ToLower(strings.TrimSpace(os.Getenv("HUMAN_GOAT_FEEDBACK_AUTO_SEND")))
+ return v == "1" || v == "true" || v == "yes"
+}
+
+func appendFeedback(entry FeedbackEntry) error {
+ p, err := feedbackFilePath()
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening feedback ledger: %w", err)
+ }
+ defer f.Close()
+ return json.NewEncoder(f).Encode(entry)
+}
+
+func postFeedback(url string, entry FeedbackEntry) error {
+ body, err := json.Marshal(entry)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("building feedback request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "human-goat-pp-cli/feedback")
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("posting feedback: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("feedback endpoint returned %s", resp.Status)
+ }
+ return nil
+}
+
+func newFeedbackCmd(flags *rootFlags) *cobra.Command {
+ var useStdin bool
+ var send bool
+ cmd := &cobra.Command{
+ Use: "feedback [text]",
+ Short: "Record feedback about this CLI (local by default; upstream opt-in)",
+ Long: `Feedback is captured locally first in the CLI data directory's feedback.jsonl.
+When ` + "`HUMAN_GOAT_FEEDBACK_ENDPOINT`" + ` is set and either --send is
+passed or ` + "`HUMAN_GOAT_FEEDBACK_AUTO_SEND=true`" + `, the entry is
+POSTed as JSON after the local write.
+
+Write what surprised you or tripped you up, not a bug report. The
+loop is: agent notices friction -> one invocation -> captured -> the
+maintainer sees it.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ var text string
+ if useStdin {
+ data, err := io.ReadAll(cmd.InOrStdin())
+ if err != nil {
+ return fmt.Errorf("reading stdin: %w", err)
+ }
+ text = strings.TrimSpace(string(data))
+ } else if len(args) > 0 {
+ text = strings.Join(args, " ")
+ }
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return fmt.Errorf("feedback text is empty (pass arguments or --stdin)")
+ }
+ truncated := false
+ if len(text) > feedbackMaxTextLen {
+ text = text[:feedbackMaxTextLen]
+ truncated = true
+ }
+
+ entry := FeedbackEntry{
+ Text: text,
+ CLI: "human-goat-pp-cli",
+ Version: version,
+ AgentID: os.Getenv("AGENT_ID"),
+ Timestamp: time.Now().UTC(),
+ }
+ if err := appendFeedback(entry); err != nil {
+ return err
+ }
+
+ upstreamResult := map[string]any{"sent": false}
+ if endpoint := feedbackEndpoint(); endpoint != "" && (send || feedbackAutoSend()) {
+ if err := postFeedback(endpoint, entry); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "warning: feedback upstream POST failed: %v\n", err)
+ upstreamResult["sent"] = false
+ upstreamResult["error"] = err.Error()
+ } else {
+ upstreamResult["sent"] = true
+ upstreamResult["endpoint"] = endpoint
+ }
+ }
+
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "recorded": true,
+ "truncated": truncated,
+ "upstream": upstreamResult,
+ "entry": entry,
+ }, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "feedback recorded locally (%d chars%s)\n", len(text), func() string {
+ if truncated {
+ return ", truncated"
+ }
+ return ""
+ }())
+ if sent, _ := upstreamResult["sent"].(bool); sent {
+ fmt.Fprintf(cmd.OutOrStdout(), "upstream POST: %v\n", upstreamResult["endpoint"])
+ }
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&useStdin, "stdin", false, "Read feedback body from stdin rather than arguments")
+ cmd.Flags().BoolVar(&send, "send", false, "POST to the configured feedback endpoint in addition to local write")
+
+ cmd.AddCommand(newFeedbackListCmd(flags))
+ return cmd
+}
+
+func newFeedbackListCmd(flags *rootFlags) *cobra.Command {
+ var limit int
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List recent feedback entries",
+ Annotations: map[string]string{
+ "mcp:read-only": "true",
+ },
+ Example: ` human-goat-pp-cli feedback list
+ human-goat-pp-cli feedback list --limit 5
+ human-goat-pp-cli feedback list --json`,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ p, err := feedbackFilePath()
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ if os.IsNotExist(err) {
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), []FeedbackEntry{}, flags)
+ }
+ return nil
+ }
+ return err
+ }
+ var entries []FeedbackEntry
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var e FeedbackEntry
+ if err := json.Unmarshal([]byte(line), &e); err != nil {
+ continue
+ }
+ entries = append(entries, e)
+ }
+ if limit > 0 && limit < len(entries) {
+ entries = entries[len(entries)-limit:]
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), entries, flags)
+ },
+ }
+ cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of recent entries to return")
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/helpers.go b/library/productivity/human-goat/internal/cli/helpers.go
new file mode 100644
index 0000000000..1e12ced207
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/helpers.go
@@ -0,0 +1,2082 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "text/tabwriter"
+ "time"
+ "unicode"
+)
+
+var As = errors.As
+
+const paginatedGetMaxPages = 100
+
+func formatCLIParamValue(v any) string {
+ if f, ok := v.(float64); ok {
+ return strconv.FormatFloat(f, 'f', -1, 64)
+ }
+ return fmt.Sprintf("%v", v)
+}
+
+// noColor is set by the --no-color flag
+var noColor bool
+
+// humanFriendly is set by the --human-friendly flag; colors are off by default (agent-safe)
+var humanFriendly bool
+
+func colorEnabled() bool {
+ if noColor {
+ return false
+ }
+ if !humanFriendly {
+ return false
+ }
+ if os.Getenv("NO_COLOR") != "" {
+ return false
+ }
+ if os.Getenv("TERM") == "dumb" {
+ return false
+ }
+ return true
+}
+
+func isTerminal(w io.Writer) bool {
+ if f, ok := w.(*os.File); ok {
+ fi, err := f.Stat()
+ if err != nil {
+ return true
+ }
+ return (fi.Mode() & os.ModeCharDevice) != 0
+ }
+ return false
+}
+
+func bold(s string) string {
+ if !colorEnabled() {
+ return s
+ }
+ return "\033[1m" + s + "\033[0m"
+}
+
+func green(s string) string {
+ if !colorEnabled() {
+ return s
+ }
+ return "\033[32m" + s + "\033[0m"
+}
+
+func red(s string) string {
+ if !colorEnabled() {
+ return s
+ }
+ return "\033[31m" + s + "\033[0m"
+}
+
+func yellow(s string) string {
+ if !colorEnabled() {
+ return s
+ }
+ return "\033[33m" + s + "\033[0m"
+}
+
+type cliError struct {
+ code int
+ err error
+}
+
+func (e *cliError) Error() string { return e.err.Error() }
+func (e *cliError) Unwrap() error { return e.err }
+
+func usageErr(err error) error { return &cliError{code: 2, err: err} }
+func notFoundErr(err error) error { return &cliError{code: 3, err: err} }
+func authErr(err error) error { return &cliError{code: 4, err: err} }
+func apiErr(err error) error { return &cliError{code: 5, err: err} }
+func configErr(err error) error { return &cliError{code: 10, err: err} }
+func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
+
+// dryRunOK reports whether the command should short-circuit without doing any
+// real work because --dry-run was set. The verify pipeline probes hand-written
+// commands with --dry-run; commands that put validation in cobra's `Args:` or
+// `MarkFlagRequired` cannot reach a dry-run guard inside RunE because cobra
+// runs those checks before RunE. The verify-friendly pattern for hand-written
+// commands is:
+//
+// RunE: func(cmd *cobra.Command, args []string) error {
+// if len(args) == 0 {
+// return cmd.Help()
+// }
+// if dryRunOK(flags) {
+// return nil
+// }
+// // ... real work ...
+// }
+//
+// See SKILL.md "Phase 3: Build The GOAT" for the full pattern.
+func dryRunOK(flags *rootFlags) bool {
+ return flags != nil && flags.dryRun
+}
+
+// boundCtx applies the root --timeout flag to hand-written command work that
+// does not go through the generated internal/client.Client. Generated endpoint
+// commands already pass flags.timeout into client.New; sibling typed clients
+// used by novel commands need an explicit command-level context boundary.
+func boundCtx(parent context.Context, flags *rootFlags) (context.Context, context.CancelFunc) {
+ if flags == nil || flags.timeout <= 0 {
+ return parent, func() {}
+ }
+ return context.WithTimeout(parent, flags.timeout)
+}
+
+// parentNoSubcommandRunE returns a RunE that handles parents invoked without a
+// subcommand. In machine output (--json/--agent) the parent emits a structured
+// error to stdout listing valid subcommands and exits 2; otherwise cobra's
+// default help text is printed. Without this, agents driving the CLI in
+// --agent mode received only human-readable help on stdout and exit 0, with no
+// signal that the invocation was incomplete.
+func parentNoSubcommandRunE(flags *rootFlags) func(*cobra.Command, []string) error {
+ return func(cmd *cobra.Command, args []string) error {
+ if flags != nil && flags.asJSON {
+ subs := make([]string, 0, len(cmd.Commands()))
+ for _, c := range cmd.Commands() {
+ if c.IsAvailableCommand() && c.Name() != "help" {
+ subs = append(subs, c.Name())
+ }
+ }
+ sort.Strings(subs)
+ _ = json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{
+ "error": "subcommand required",
+ "valid_subcommands": subs,
+ })
+ return usageErr(fmt.Errorf("subcommand required for %q", cmd.CommandPath()))
+ }
+ return cmd.Help()
+ }
+}
+
+// accessWarning describes an API access-denial that sync converts into a
+// non-fatal warning. It carries enough structured data for the sync_warning
+// JSON event without parsing free-form error strings downstream.
+type accessWarning struct {
+ Status int // HTTP status when applicable; 0 for GraphQL field-level denials.
+ Reason string // "forbidden" | "insufficient_access" | "unauthenticated"
+ Message string // human-readable detail (the API's body or GraphQL error message)
+}
+
+// syncErrorJSON returns a one-line JSON sync_error event. When err wraps a
+// *client.APIError, the structured status/method/path/body fields let
+// operators see the API's response body without parsing a wrapped error
+// string — required for diagnosing 4xx responses on endpoints whose OpenAPI
+// spec marks filter params optional but the API treats them as required.
+//
+// parent is non-empty only on the dependent-resource path, where it
+// identifies which parent ID's child request failed.
+func syncErrorJSON(resource, parent string, err error) string {
+ payload := struct {
+ Event string `json:"event"`
+ Resource string `json:"resource"`
+ Parent string `json:"parent,omitempty"`
+ Status int `json:"status,omitempty"`
+ Method string `json:"method,omitempty"`
+ Path string `json:"path,omitempty"`
+ Body string `json:"body,omitempty"`
+ Error string `json:"error"`
+ }{
+ Event: "sync_error",
+ Resource: resource,
+ Parent: parent,
+ Error: err.Error(),
+ }
+ var apiErr *client.APIError
+ if errors.As(err, &apiErr) {
+ payload.Status = apiErr.StatusCode
+ payload.Method = apiErr.Method
+ payload.Path = apiErr.Path
+ payload.Body = apiErr.Body
+ }
+ out, _ := json.Marshal(payload)
+ return string(out)
+}
+
+// syncWarningJSON renders a sync_warning event as a single valid JSON line.
+// Marshaling (rather than fmt.Fprintf string interpolation) escapes the
+// message field, whose value is an upstream error body that may be
+// pretty-printed multi-line JSON — embedding it raw broke the NDJSON stream.
+func syncWarningJSON(resource, parent string, status int, reason, message string) string {
+ payload := struct {
+ Event string `json:"event"`
+ Resource string `json:"resource"`
+ Parent string `json:"parent,omitempty"`
+ // status/reason/message are always present (matching the prior raw
+ // fmt.Fprintf shape); only parent was conditional. No omitempty so
+ // consumers parsing the event don't see fields disappear on zero values.
+ Status int `json:"status"`
+ Reason string `json:"reason"`
+ Message string `json:"message"`
+ }{
+ Event: "sync_warning",
+ Resource: resource,
+ Parent: parent,
+ Status: status,
+ Reason: reason,
+ Message: message,
+ }
+ out, _ := json.Marshal(payload)
+ return string(out)
+}
+
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace= belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects//tasks?workspace=) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
+type syncUserParams struct {
+ flatGlobal map[string]string
+ trueGlobal map[string]string
+ perResource map[string]map[string]string
+}
+
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+ flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+ if err != nil {
+ return nil, err
+ }
+ trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+ if err != nil {
+ return nil, err
+ }
+ p := &syncUserParams{
+ flatGlobal: flatGlobal,
+ trueGlobal: trueGlobal,
+ perResource: map[string]map[string]string{},
+ }
+ for _, spec := range resourceParamFlags {
+ resource, kv, ok := strings.Cut(spec, ":")
+ if !ok || resource == "" {
+ return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
+ }
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok || k == "" {
+ return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
+ }
+ if p.perResource[resource] == nil {
+ p.perResource[resource] = map[string]string{}
+ }
+ p.perResource[resource][k] = v
+ }
+ return p, nil
+}
+
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+ out := map[string]string{}
+ for _, kv := range flags {
+ k, v, ok := strings.Cut(kv, "=")
+ if !ok || k == "" {
+ return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+ }
+ out[k] = v
+ }
+ return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
+ if p == nil {
+ return
+ }
+ if !isDependent {
+ for k, v := range p.flatGlobal {
+ params[k] = v
+ }
+ }
+ for k, v := range p.trueGlobal {
+ params[k] = v
+ }
+ for k, v := range p.perResource[resource] {
+ params[k] = v
+ }
+}
+
+// validateResourceNames returns a usage-shaped error when any --resource-param
+// key targets a resource not in known. Without this check a typo
+// (e.g. --resource-param chanels:mine=true) is a silent no-op: the param
+// never matches a real resource and sync proceeds with missing rows.
+func (p *syncUserParams) validateResourceNames(known []string) error {
+ if p == nil || len(p.perResource) == 0 {
+ return nil
+ }
+ set := make(map[string]struct{}, len(known))
+ for _, r := range known {
+ set[r] = struct{}{}
+ }
+ var unknown []string
+ for r := range p.perResource {
+ if _, ok := set[r]; !ok {
+ unknown = append(unknown, r)
+ }
+ }
+ if len(unknown) == 0 {
+ return nil
+ }
+ sort.Strings(unknown)
+ return fmt.Errorf("--resource-param references unknown resource(s): %s (known: %s)",
+ strings.Join(unknown, ", "), strings.Join(known, ", "))
+}
+
+// accessDenialPatterns matches API error bodies that indicate the request was
+// rejected for access-policy reasons rather than for input validity. Matching
+// is case-insensitive and uses word boundaries so common substrings inside
+// unrelated tokens (e.g. "author", "pagination_token", "insufficient_funds")
+// do not produce false positives. The set deliberately excludes brand names —
+// vendor-specific phrasings should be addressed at the spec/profiler level,
+// not in this universal classifier.
+var accessDenialPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`\bforbidden\b`),
+ regexp.MustCompile(`\bunauthorized\b`),
+ regexp.MustCompile(`\bnot[\s_-]?authorized\b`),
+ regexp.MustCompile(`\bpermission[\s_-]?denied\b`),
+ regexp.MustCompile(`\baccess[\s_-]?denied\b`),
+ regexp.MustCompile(`\binsufficient[\s_-]?(scope|permission|privilege)`),
+ regexp.MustCompile(`\binvalid[\s_-]?scope\b`),
+ regexp.MustCompile(`\bmissing[\s_-]?scope\b`),
+ regexp.MustCompile(`\brequires?\s+(elevated|admin|enterprise|business|workspace|enterprise[\s_-]?tier)`),
+}
+
+// looksLikeAccessDenial reports whether body text describes an access-policy
+// rejection. Use it on response-body content (apiErr.Body), not on the full
+// error string — the request path can contain words like "auth" or "tokens"
+// that would produce false positives if the whole error message were scanned.
+func looksLikeAccessDenial(body string) bool {
+ lower := strings.ToLower(body)
+ for _, p := range accessDenialPatterns {
+ if p.MatchString(lower) {
+ return true
+ }
+ }
+ return false
+}
+
+// argumentMissingPatterns matches API error bodies that indicate the endpoint
+// requires a filter or identifier the vendor spec did not mark as required.
+// Each pattern keeps the missing/required/not-provided signal adjacent to an
+// argument noun so an unrelated 400 (e.g. "required field 'email' format is
+// invalid and the avatar is missing", "no results were provided") is not
+// demoted from a hard failure to a warning.
+var argumentMissingPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`\bargument(?:\s+is)?\s+missing\b`),
+ regexp.MustCompile(`\bmissing\s+(?:a\s+|an\s+|the\s+)?(?:required\s+)?(?:argument|parameter|param|field|filter|identifier|id)\b`),
+ regexp.MustCompile(`\brequired\s+(?:argument|parameter|param|filter|identifier|id)\b[^.\n]*\b(?:is\s+)?(?:missing|not\s+provided|not\s+supplied)\b`),
+ regexp.MustCompile(`\b(?:argument|parameter|param|filter)\s+(?:is\s+)?required\b`),
+ regexp.MustCompile(`\bno\s+(?:argument|parameter|param|filter|identifier|id)\b[^.\n]*\bprovided\b`),
+}
+
+func looksLikeArgumentMissing(body string) bool {
+ lower := strings.ToLower(body)
+ for _, p := range argumentMissingPatterns {
+ if p.MatchString(lower) {
+ return true
+ }
+ }
+ return false
+}
+
+// isSyncAccessWarning classifies err as an access-denial warning suitable for
+// sync's warn-and-continue path. It returns nil, false for any error that
+// should remain a hard sync failure: HTTP 401 (token-level auth failure
+// requiring re-auth), 5xx, network errors, and HTTP 400 responses whose
+// bodies do not match an access-policy pattern.
+//
+// Recognized warning shapes:
+// - HTTP 403 (per-resource ACL rejection)
+// - HTTP 400 + access-denial body keyword (insufficient scope, etc.)
+// - HTTP 400 + missing required argument body keyword
+// - GraphQL response carrying only access-denial extension codes
+func isSyncAccessWarning(err error) (*accessWarning, bool) {
+ if err == nil {
+ return nil, false
+ }
+
+ var apiErr *client.APIError
+ if errors.As(err, &apiErr) {
+ switch apiErr.StatusCode {
+ case 403:
+ return &accessWarning{Status: 403, Reason: "forbidden", Message: apiErr.Body}, true
+ case 400:
+ if looksLikeAccessDenial(apiErr.Body) {
+ return &accessWarning{Status: 400, Reason: "insufficient_access", Message: apiErr.Body}, true
+ }
+ if looksLikeArgumentMissing(apiErr.Body) {
+ return &accessWarning{Status: 400, Reason: "argument_missing", Message: apiErr.Body}, true
+ }
+ }
+ }
+
+ return nil, false
+}
+
+type noopResult struct {
+ Status string `json:"status"`
+ Reason string `json:"reason"`
+}
+
+func writeNoop(flags *rootFlags, reason, prose string) error {
+ if flags != nil && flags.asJSON {
+ return json.NewEncoder(os.Stdout).Encode(noopResult{Status: "noop", Reason: reason})
+ }
+ fmt.Fprintln(os.Stderr, prose)
+ return nil
+}
+
+func writeAPIErrorEnvelope(flags *rootFlags, err error, code int) {
+ if flags == nil || !flags.asJSON {
+ return
+ }
+ _ = json.NewEncoder(os.Stdout).Encode(map[string]any{
+ "error": err.Error(),
+ "code": code,
+ })
+}
+
+// classifyAPIError maps API errors to structured exit codes with actionable hints.
+func classifyAPIError(err error, flags *rootFlags) error {
+ var typed *cliError
+ if errors.As(err, &typed) {
+ return err
+ }
+
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "HTTP 409"):
+ classified := apiErr(err)
+ writeAPIErrorEnvelope(flags, classified, ExitCode(classified))
+ return classified
+ case errors.Is(err, client.ErrPlaceholderCredential):
+ return authErr(err)
+ case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+ return authErr(fmt.Errorf("%w\nhint: the API rejected the request — this usually means auth is missing or invalid."+
+ "\n Run 'human-goat-pp-cli auth login --chrome' to refresh browser-session credentials."+
+ "\n Run 'human-goat-pp-cli doctor' to check auth status."+
+ "\n Response: "+cliutil.SanitizeErrorBody(msg), err))
+ case strings.Contains(msg, "HTTP 401"):
+ return authErr(fmt.Errorf("%w\nhint: check your API credentials."+
+ " Run 'human-goat-pp-cli auth login --chrome' to refresh browser-session credentials."+
+ "\n Run 'human-goat-pp-cli doctor' to check auth status.", err))
+ case strings.Contains(msg, "HTTP 403"):
+ return authErr(fmt.Errorf("%w\nhint: permission denied. Your credentials are valid but lack access to this resource."+
+ "\n Check that your credentials have the required permissions and match the API's expected auth scheme."+
+ "\n Run 'human-goat-pp-cli auth login --chrome' to refresh browser-session credentials."+
+ "\n Run 'human-goat-pp-cli doctor' to check auth status.", err))
+ case strings.Contains(msg, "HTTP 404"):
+ return notFoundErr(fmt.Errorf("%w\nhint: resource not found. Run the 'list' command to see available items", err))
+ case strings.Contains(msg, "HTTP 429"):
+ return rateLimitErr(err)
+ default:
+ return apiErr(err)
+ }
+}
+
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ if max <= 3 {
+ return s[:max]
+ }
+ return s[:max-3] + "..."
+}
+
+func newTabWriter(w io.Writer) *tabwriter.Writer {
+ return tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
+}
+
+// paginatedGet fetches pages and concatenates array results. The headers
+// argument carries per-endpoint required headers (e.g. cal-api-version) that
+// must be sent on every page request, including the first; pass nil when the
+// endpoint has no per-endpoint header overrides.
+func paginatedGet(ctx context.Context, c interface {
+ GetWithHeaders(ctx context.Context, path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, path string, params map[string]string, headers map[string]string, fetchAll bool, cursorParam, paginationType, limitParam, nextCursorPath, hasMoreField string) (json.RawMessage, error) {
+ // Cursor params are exempt from the "0"/"false" strip: offset-paginated
+ // APIs send offset=0 on the first page.
+ clean := map[string]string{}
+ for k, v := range params {
+ if v == "" {
+ continue
+ }
+ if k == cursorParam || (v != "0" && v != "false") {
+ clean[k] = v
+ }
+ }
+
+ if !fetchAll {
+ data, err := c.GetWithHeaders(ctx, path, clean, headers)
+ if err != nil {
+ return nil, err
+ }
+ emitTruncationWarning(data, nextCursorPath, hasMoreField, paginationType)
+ return data, nil
+ }
+
+ // Fetch all pages
+ allItems := make([]json.RawMessage, 0)
+ page := 0
+ for {
+ page++
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "fetching page %d...\n", page)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"page_fetch","page":%d}`+"\n", page)
+ }
+
+ data, err := c.GetWithHeaders(ctx, path, clean, headers)
+ if err != nil {
+ return nil, err
+ }
+
+ // Try to extract items array
+ var items []json.RawMessage
+ if json.Unmarshal(data, &items) == nil {
+ allItems = append(allItems, items...)
+ if next, ok := nextFullPageOffsetCursor(clean, cursorParam, paginationType, limitParam, len(items)); ok {
+ if page >= paginatedGetMaxPages {
+ emitPaginatedGetMaxPagesWarning()
+ break
+ }
+ clean[cursorParam] = next
+ continue
+ }
+ } else {
+ // Response is an object - look for array inside
+ var obj map[string]json.RawMessage
+ if json.Unmarshal(data, &obj) == nil {
+ itemCount := 0
+ if nested, ok := extractPaginatedItems(obj); ok {
+ allItems = append(allItems, nested...)
+ itemCount = len(nested)
+ }
+
+ // Check for next cursor
+ if nextCursorPath != "" {
+ if tokenRaw, ok := rawAtPath(obj, nextCursorPath); ok {
+ if token := paginationCursorToken(tokenRaw); token != "" {
+ if page >= paginatedGetMaxPages {
+ emitPaginatedGetMaxPagesWarning()
+ break
+ }
+ clean[cursorParam] = token
+ continue
+ }
+ }
+ }
+
+ // Check has_more. Page and offset paginators can advance
+ // client-side; cursor-based APIs still need a body cursor.
+ hasExplicitNoMore := false
+ if hasMoreField != "" {
+ if moreRaw, ok := rawAtPath(obj, hasMoreField); ok {
+ var more bool
+ if json.Unmarshal(moreRaw, &more) == nil {
+ if more {
+ if next, ok := nextClientSidePaginationCursor(clean, cursorParam, paginationType, limitParam); ok {
+ if page >= paginatedGetMaxPages {
+ emitPaginatedGetMaxPagesWarning()
+ break
+ }
+ clean[cursorParam] = next
+ continue
+ }
+ emitMissingPaginationCursorWarning(nextCursorPath)
+ break
+ }
+ hasExplicitNoMore = true
+ }
+ }
+ }
+ if !hasExplicitNoMore && nextCursorPath == "" && hasMoreField == "" {
+ if next, ok := nextFullPageOffsetCursor(clean, cursorParam, paginationType, limitParam, itemCount); ok {
+ if page >= paginatedGetMaxPages {
+ emitPaginatedGetMaxPagesWarning()
+ break
+ }
+ clean[cursorParam] = next
+ continue
+ }
+ }
+ }
+ // No more pages
+ break
+ }
+
+ // For direct arrays, can't paginate without cursor
+ break
+ }
+
+ if fetchAll && page == 1 && nextCursorPath == "" && hasMoreField == "" && paginationType != "offset" && paginationType != "page" {
+ emitMissingPaginationSignalWarning()
+ }
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "fetched %d items across %d pages\n", len(allItems), page)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"complete","total":%d,"pages":%d}`+"\n", len(allItems), page)
+ }
+ result, _ := json.Marshal(allItems)
+ return json.RawMessage(result), nil
+}
+
+func nextFullPageOffsetCursor(params map[string]string, cursorParam, paginationType, limitParam string, itemCount int) (string, bool) {
+ if (paginationType != "offset" && paginationType != "page") || itemCount == 0 {
+ return "", false
+ }
+ limit, err := strconv.Atoi(params[limitParam])
+ if err != nil || limit <= 0 || itemCount < limit {
+ return "", false
+ }
+ return nextClientSidePaginationCursor(params, cursorParam, paginationType, limitParam)
+}
+
+func nextClientSidePaginationCursor(params map[string]string, cursorParam, paginationType, limitParam string) (string, bool) {
+ if cursorParam == "" {
+ return "", false
+ }
+ switch paginationType {
+ case "page":
+ current := params[cursorParam]
+ if current == "" {
+ current = "1"
+ }
+ n, err := strconv.Atoi(current)
+ if err != nil {
+ return "", false
+ }
+ return strconv.Itoa(n + 1), true
+ case "offset":
+ current := params[cursorParam]
+ if current == "" {
+ current = "0"
+ }
+ n, err := strconv.Atoi(current)
+ if err != nil {
+ return "", false
+ }
+ limit, err := strconv.Atoi(params[limitParam])
+ if err != nil || limit <= 0 {
+ return "", false
+ }
+ return strconv.Itoa(n + limit), true
+ default:
+ return "", false
+ }
+}
+
+// Silent page-1 truncation is the worst-possible mode for agents,
+// who otherwise compute totals against an incomplete set without
+// passing --all.
+func emitTruncationWarning(data json.RawMessage, nextCursorPath, hasMoreField, paginationType string) {
+ if nextCursorPath == "" && hasMoreField == "" {
+ return
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return
+ }
+ var nextCursor string
+ if nextCursorPath != "" {
+ if tokenRaw, ok := rawAtPath(obj, nextCursorPath); ok {
+ nextCursor = paginationCursorToken(tokenRaw)
+ }
+ }
+ var hasMore bool
+ if hasMoreField != "" {
+ if moreRaw, ok := rawAtPath(obj, hasMoreField); ok {
+ _ = json.Unmarshal(moreRaw, &hasMore)
+ }
+ }
+ if nextCursor == "" && !hasMore {
+ return
+ }
+ // --all advances when a next-cursor is configured, or when the endpoint
+ // uses client-side numeric page/offset advancement. Opaque cursor APIs
+ // still need a returned cursor to advance safely.
+ if nextCursor != "" || ((paginationType == "page" || paginationType == "offset") && hasMore) {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available. Re-run with --all to fetch every page.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","hint":"pass --all to fetch every page"}`+"\n")
+ }
+ return
+ }
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated"}`+"\n")
+ }
+}
+
+func emitMissingPaginationSignalWarning() {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: --all requested, but this endpoint does not declare a next cursor or has-more field; returning page 1 only.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","reason":"pagination_signal_missing","message":"--all requested but this endpoint does not declare a next cursor or has-more field; returning page 1 only"}`+"\n")
+ }
+}
+
+func emitPaginatedGetMaxPagesWarning() {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: --all reached the %d-page safety limit; returning fetched pages only.\n", paginatedGetMaxPages)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","reason":"max_pages_cap_hit","message":"--all reached the %d-page safety limit; returning fetched pages only"}`+"\n", paginatedGetMaxPages)
+ }
+}
+
+func emitMissingPaginationCursorWarning(nextCursorPath string) {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: --all requested, but the response indicated more pages without a usable next cursor; returning fetched pages only.\n")
+ } else if nextCursorPath != "" {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","reason":"pagination_cursor_missing","next_cursor_path":%q,"message":"--all requested but the response indicated more pages without a usable next cursor; returning fetched pages only"}`+"\n", nextCursorPath)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"truncated","reason":"pagination_cursor_missing","message":"--all requested but the response indicated more pages without a usable next cursor; returning fetched pages only"}`+"\n")
+ }
+}
+
+func paginationCursorToken(raw json.RawMessage) string {
+ var token string
+ if json.Unmarshal(raw, &token) == nil && token != "" {
+ return token
+ }
+ var number json.Number
+ if json.Unmarshal(raw, &number) == nil {
+ if n, err := number.Int64(); err == nil && n > 0 {
+ return number.String()
+ }
+ }
+ return ""
+}
+
+func extractPaginatedItems(obj map[string]json.RawMessage) ([]json.RawMessage, bool) {
+ for _, field := range []string{"data", "items", "results", "messages", "members", "values"} {
+ if arr, ok := obj[field]; ok {
+ var nested []json.RawMessage
+ if json.Unmarshal(arr, &nested) == nil {
+ return nested, true
+ }
+ }
+ }
+
+ var onlyArray []json.RawMessage
+ arrayCount := 0
+ for key, raw := range obj {
+ if envelopeMetadataArrayKeys[key] {
+ continue
+ }
+ if candidate, ok := extractPaginatedObjectArray(raw); ok {
+ onlyArray = candidate
+ arrayCount++
+ }
+ }
+ if arrayCount == 1 {
+ return onlyArray, true
+ }
+ return nil, false
+}
+
+// envelopeMetadataArrayKeys lists sidecar arrays that must not be mistaken for
+// a domain collection when projecting wrapped output or aggregating pages.
+var envelopeMetadataArrayKeys = map[string]bool{
+ "errors": true, "Errors": true,
+ "warnings": true, "Warnings": true,
+}
+
+func extractPaginatedObjectArray(raw json.RawMessage) ([]json.RawMessage, bool) {
+ var items []json.RawMessage
+ // Empty fallback arrays are deliberately ignored: without an object item,
+ // there is no signal distinguishing a domain collection from metadata.
+ if err := json.Unmarshal(raw, &items); err != nil || len(items) == 0 {
+ return nil, false
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(items[0], &obj); err != nil {
+ return nil, false
+ }
+ return items, true
+}
+
+func rawAtPath(obj map[string]json.RawMessage, path string) (json.RawMessage, bool) {
+ if raw, ok := obj[path]; ok {
+ return raw, true
+ }
+
+ current := obj
+ parts := strings.Split(path, ".")
+ for i, part := range parts {
+ raw, ok := current[part]
+ if !ok {
+ return nil, false
+ }
+ if i == len(parts)-1 {
+ return raw, true
+ }
+ if err := json.Unmarshal(raw, ¤t); err != nil {
+ return nil, false
+ }
+ }
+ return nil, false
+}
+
+func applyResponsePath(data json.RawMessage, responsePath string) json.RawMessage {
+ if pathData, ok := responsePayloadAtPath(data, responsePath); ok {
+ return pathData
+ }
+ return data
+}
+
+func responsePayloadAtPath(data json.RawMessage, responsePath string) (json.RawMessage, bool) {
+ if strings.TrimSpace(responsePath) == "" {
+ return data, false
+ }
+ var root map[string]json.RawMessage
+ if err := json.Unmarshal(data, &root); err != nil {
+ return nil, false
+ }
+ return rawAtPath(root, strings.TrimPrefix(responsePath, "$."))
+}
+
+func responsePayloadParentAtPath(data json.RawMessage, responsePath string) (map[string]json.RawMessage, bool) {
+ path := strings.TrimPrefix(strings.TrimSpace(responsePath), "$.")
+ if path == "" {
+ return nil, false
+ }
+ var current map[string]json.RawMessage
+ if err := json.Unmarshal(data, ¤t); err != nil {
+ return nil, false
+ }
+ parts := strings.Split(path, ".")
+ if len(parts) == 1 {
+ return current, true
+ }
+ for _, part := range parts[:len(parts)-1] {
+ raw, ok := current[part]
+ if !ok {
+ return nil, false
+ }
+ if err := json.Unmarshal(raw, ¤t); err != nil {
+ return nil, false
+ }
+ }
+ return current, true
+}
+
+// printJSONFiltered marshals a Go-typed value through the same output
+// pipeline endpoint-mirror commands use. Hand-written novel commands that
+// build a typed slice/struct call this so --select, --compact, --csv, and
+// --quiet all behave the same way as on generator-emitted commands.
+func printJSONFiltered(w io.Writer, v any, flags *rootFlags) error {
+ raw, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ return printOutputWithFlags(w, json.RawMessage(raw), flags)
+}
+
+// wrapAgentOutput gives --agent callers one parseable top-level envelope for
+// generated command families that build typed Go values instead of endpoint
+// response bytes. The raw value is preserved under results so --json without
+// --agent can stay backward-compatible while shell agents get stable metadata.
+func wrapAgentOutput(data json.RawMessage, meta map[string]any) (json.RawMessage, error) {
+ if meta == nil {
+ meta = map[string]any{}
+ }
+ if _, ok := meta["source"]; !ok {
+ meta["source"] = "local"
+ }
+ if source, _ := meta["source"].(string); source == "live" {
+ data = unwrapSingleKeyArray(data)
+ }
+ var results any
+ if json.Valid(data) {
+ results = data
+ } else {
+ results = string(data)
+ }
+ envelope := map[string]any{
+ "meta": meta,
+ "results": results,
+ }
+ return json.Marshal(envelope)
+}
+
+// unwrapSingleKeyArray flattens single-key collection envelopes
+// ({"results":[...]}, {"data":[...]}, etc.) so the agent envelope
+// emits a stable .results[] across APIs. Multi-key objects pass
+// through so cursor/pagination fields stay accessible; non-array
+// values pass through so non-collection responses aren't reshaped.
+//
+// The wrapper-key set is intentionally narrower than
+// extractPaginatedItems (which also walks domain-specific keys like
+// "messages", "members", "values" used by social/messaging APIs).
+// This helper only flattens canonical collection envelopes for
+// --json output; the pagination walker has a broader remit.
+func unwrapSingleKeyArray(data json.RawMessage) json.RawMessage {
+ leading := bytes.TrimLeft(data, " \t\r\n")
+ if len(leading) == 0 || leading[0] != '{' {
+ return data
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return data
+ }
+ if len(obj) != 1 {
+ return data
+ }
+ for key, val := range obj {
+ if key != "results" && key != "data" && key != "items" && key != "nodes" && key != "entries" && key != "records" {
+ return data
+ }
+ trimmed := bytes.TrimLeft(val, " \t\r\n")
+ if len(trimmed) == 0 || trimmed[0] != '[' {
+ return data
+ }
+ return val
+ }
+ return data
+}
+
+// filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.
+// Supports dotted paths like "events.shortName" to descend into nested structures.
+// Arrays are traversed element-wise: "events.shortName" keeps shortName on each event.
+func filterFields(data json.RawMessage, fields string) json.RawMessage {
+ var paths [][]string
+ for _, f := range strings.Split(fields, ",") {
+ f = strings.TrimSpace(f)
+ if f == "" {
+ continue
+ }
+ parts := strings.Split(f, ".")
+ for i := range parts {
+ parts[i] = strings.ToLower(parts[i])
+ }
+ paths = append(paths, parts)
+ }
+ if len(paths) == 0 {
+ return data
+ }
+ return filterFieldsRec(data, paths)
+}
+
+// filterFieldsRec applies path filters to a JSON value. Each path is a list of
+// lowercase segments; arrays descend element-wise.
+func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
+ var arr []json.RawMessage
+ if err := json.Unmarshal(data, &arr); err == nil {
+ out := make([]json.RawMessage, len(arr))
+ for i, el := range arr {
+ out[i] = filterFieldsRec(el, paths)
+ }
+ result, _ := json.Marshal(out)
+ return result
+ }
+
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err == nil {
+ keepWhole := map[string]bool{}
+ subPaths := map[string][][]string{}
+ for _, p := range paths {
+ if len(p) == 0 {
+ continue
+ }
+ head := p[0]
+ if len(p) == 1 {
+ keepWhole[head] = true
+ } else {
+ subPaths[head] = append(subPaths[head], p[1:])
+ }
+ }
+ filtered := map[string]json.RawMessage{}
+ matchedAny := false
+ for k, v := range obj {
+ matched := matchSelectSegment(k, keepWhole, subPaths)
+ if matched == "" {
+ continue
+ }
+ matchedAny = true
+ if keepWhole[matched] {
+ filtered[k] = v
+ continue
+ }
+ if subs := subPaths[matched]; subs != nil {
+ filtered[k] = filterFieldsRec(v, subs)
+ }
+ }
+ // Envelope fallback: when no top-level keys matched but at least one
+ // sibling is a non-null array, treat the object as a list envelope
+ // (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+ // and apply the selector inside the array(s). Non-array siblings pass
+ // through verbatim so envelope metadata (counts, null pagination
+ // cursors) stays visible. The foundArray guard preserves the prior
+ // empty-object result for flat objects where no key matches and no
+ // array exists. The `arr != nil` check rejects JSON null, which
+ // json.Unmarshal otherwise accepts into a []json.RawMessage as a
+ // nil slice and would coerce to `[]`.
+ if !matchedAny {
+ if pending, foundArray := filterListEnvelopeFields(obj, paths); foundArray {
+ filtered = pending
+ }
+ }
+ result, _ := json.Marshal(filtered)
+ return result
+ }
+
+ return data
+}
+
+func filterListEnvelopeFields(obj map[string]json.RawMessage, paths [][]string) (map[string]json.RawMessage, bool) {
+ pending := map[string]json.RawMessage{}
+ foundArray := false
+ for k, v := range obj {
+ if envelopeMetadataArrayKeys[k] {
+ pending[k] = v
+ continue
+ }
+ var arr []json.RawMessage
+ if json.Unmarshal(v, &arr) == nil && arr != nil {
+ foundArray = true
+ pending[k] = filterFieldsRec(v, paths)
+ continue
+ }
+ if k == "_embedded" {
+ if nested, ok := filterNestedListEnvelopeFields(v, paths); ok {
+ foundArray = true
+ pending[k] = nested
+ continue
+ }
+ }
+ pending[k] = v
+ }
+ return pending, foundArray
+}
+
+func filterNestedListEnvelopeFields(data json.RawMessage, paths [][]string) (json.RawMessage, bool) {
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return nil, false
+ }
+ filtered, found := filterListEnvelopeFields(obj, paths)
+ if !found {
+ return nil, false
+ }
+ result, _ := json.Marshal(filtered)
+ return result, true
+}
+
+// matchSelectSegment returns the matching lowercase segment, or "" if no match.
+// Supports direct case-insensitive match and camelCase→kebab-case conversion.
+func matchSelectSegment(fieldName string, keepWhole map[string]bool, subPaths map[string][][]string) string {
+ lower := strings.ToLower(fieldName)
+ if keepWhole[lower] || subPaths[lower] != nil {
+ return lower
+ }
+ kebab := camelToKebab(fieldName)
+ if kebab != lower && (keepWhole[kebab] || subPaths[kebab] != nil) {
+ return kebab
+ }
+ return ""
+}
+
+// camelToKebab converts "orderDate" or "orderdate" to "order-date" by splitting on
+// uppercase boundaries. For already-lowercase input, splits on known word boundaries.
+func camelToKebab(s string) string {
+ var b strings.Builder
+ runes := []rune(s)
+ for i, r := range runes {
+ if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+ b.WriteByte('-')
+ }
+ b.WriteRune(unicode.ToLower(r))
+ }
+ return b.String()
+}
+
+// printOutputWithFlags routes output through the right format based on flags.
+func printOutputWithFlags(w io.Writer, data json.RawMessage, flags *rootFlags) error {
+ return printOutputWithFlagsMeta(w, data, flags, map[string]any{"source": "local"})
+}
+
+func printOutputWithFlagsMeta(w io.Writer, data json.RawMessage, flags *rootFlags, agentMeta map[string]any) error {
+ // --select wins over --compact when both are set: an explicit field list
+ // is the user's authoritative request, so the high-gravity allow-list
+ // must not strip those fields out before --select can pick them. When
+ // only --compact is set (e.g., --agent without --select), the allow-list
+ // still runs.
+ if flags.selectFields != "" {
+ data = filterFields(data, flags.selectFields)
+ } else if flags.compact {
+ data = compactFields(data)
+ }
+ if flags.agent && flags.asJSON && !flags.csv && !flags.plain && !flags.quiet {
+ wrapped, err := wrapAgentOutput(data, agentMeta)
+ if err != nil {
+ return err
+ }
+ data = wrapped
+ }
+ // --quiet: suppress all output, exit code communicates result
+ if flags.quiet {
+ return nil
+ }
+ // --csv: render as CSV
+ if flags.csv {
+ return printCSV(w, data)
+ }
+ // --plain: render arrays as tab-separated rows
+ if flags.plain {
+ return printPlain(w, data)
+ }
+ return printOutput(w, data, flags.asJSON)
+}
+
+// compactVerboseListFields are prose-shaped fields stripped from list-item
+// projections. On lists, "body"/"content"/"html"/"markdown" are verbose
+// noise and the row's identity is carried by id/name/title/etc.
+var compactVerboseListFields = map[string]bool{
+ "description": true, "body": true, "content": true,
+ "comments": true, "attachments": true, "html": true, "markdown": true,
+ "_links": true, "links": true,
+}
+
+// compactVerboseObjectFields are metadata fields stripped from single-object
+// responses. "body"/"content"/"html"/"markdown" are intentionally absent:
+// for a `get` command those fields are the primary payload, and stripping
+// them under `--agent`/`--compact` silently emits a useless envelope.
+// Use `--select` to drop them explicitly.
+var compactVerboseObjectFields = map[string]bool{
+ "description": true,
+ "comments": true,
+ "attachments": true,
+}
+
+// compactFields keeps only the most important fields for agent consumption.
+// For arrays: allowlist of high-gravity fields (no descriptions).
+// For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
+func compactFields(data json.RawMessage) json.RawMessage {
+ // Try array first
+ var items []map[string]any
+ if err := json.Unmarshal(data, &items); err == nil {
+ return compactListFields(items)
+ }
+
+ // Single object — use blocklist
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err == nil {
+ return compactObjectFields(obj)
+ }
+
+ return data
+}
+
+// compactListFields keeps only high-gravity fields for array responses.
+//
+// Two-layer keep rule:
+//
+// 1. A static allow-list covers canonical scalars (id/name/price/status/...).
+// 2. A data-driven extension also keeps any key present in at least 80% of
+// input rows. This catches hand-written novel commands whose payload keys
+// (object_name, match_key, snippet, series, metrics) aren't on the
+// canonical allow-list, without forcing every printed CLI to expand the
+// list.
+//
+// Verbose fields (description, body, content, etc.) are excluded from the
+// data-driven extension regardless of frequency, so the compact intent
+// (short identifying values for agent consumption, not full prose) is
+// preserved.
+//
+// When an item still carries none of the keep keys, the original is
+// preserved so `--agent` does not silently emit {} for shapes whose key
+// names are entirely off-canonical.
+func compactListFields(items []map[string]any) json.RawMessage {
+ keepFields := map[string]bool{
+ // Identity
+ "id": true, "name": true, "title": true, "identifier": true,
+ "code": true, "slug": true, "key": true,
+ // Categorization
+ "status": true, "state": true, "type": true, "kind": true, "priority": true,
+ // Communication
+ "url": true, "email": true,
+ // Monetary
+ "price": true, "amount": true, "cost": true, "fare": true,
+ "rate": true, "currency": true,
+ // Metrics
+ "rating": true, "score": true, "count": true,
+ // Locale / geo
+ "language": true, "locale": true, "country": true, "region": true,
+ "city": true, "domain": true,
+ // Temporal
+ "created_at": true, "updated_at": true, "createdAt": true, "updatedAt": true,
+ "date": true,
+ // Versioning
+ "version": true,
+ }
+ if len(items) > 0 {
+ keyCounts := map[string]int{}
+ for _, item := range items {
+ for k := range item {
+ if compactVerboseListFields[k] {
+ continue
+ }
+ keyCounts[k]++
+ }
+ }
+ // ceil(len(items) * 0.8) without importing math. Capped at len-1 for
+ // len >= 2 so a single missing row cannot veto a key on small lists
+ // (without the cap, ceil(0.8*n) == n for n in {2,3,4}, which silently
+ // reintroduces the partial-strip bug whenever a heterogeneous 2-4 row
+ // response mixes one allow-list key with novel keys).
+ threshold := (len(items)*4 + 4) / 5
+ if len(items) >= 2 && threshold > len(items)-1 {
+ threshold = len(items) - 1
+ }
+ for k, count := range keyCounts {
+ if count >= threshold {
+ keepFields[k] = true
+ }
+ }
+ }
+
+ filtered := make([]map[string]any, 0, len(items))
+ for _, item := range items {
+ compact := map[string]any{}
+ for k, v := range item {
+ if keepFields[k] {
+ compact[k] = v
+ }
+ }
+ if len(compact) == 0 {
+ compact = item
+ }
+ filtered = append(filtered, compact)
+ }
+ result, _ := json.Marshal(filtered)
+ return result
+}
+
+// isCompactScalar reports whether v is a small primitive (string, number,
+// bool, null) suitable for compact table decisions. Compact list projection
+// may still retain frequent nested payload fields; this helper is about
+// display density, not whether a field carries agent-useful payload.
+func isCompactScalar(v any) bool {
+ switch v.(type) {
+ case nil, bool, float64, string:
+ return true
+ default:
+ return false
+ }
+}
+
+// compactObjectFields strips known-verbose metadata fields from single-object
+// responses. The blocklist deliberately excludes "body"/"content"/"html"/
+// "markdown" — those fields are payload on `get` commands and stripping them
+// under `--agent`/`--compact` is a silent loss; agents who want to omit them
+// can pass `--select` to specify only the fields they need.
+func compactObjectFields(obj map[string]any) json.RawMessage {
+ if compacted, ok := compactListEnvelopeObject(obj); ok {
+ result, _ := json.Marshal(compacted)
+ return result
+ }
+ compact := map[string]any{}
+ for k, v := range obj {
+ if !compactVerboseObjectFields[k] {
+ compact[k] = v
+ }
+ }
+ result, _ := json.Marshal(compact)
+ return result
+}
+
+func compactListEnvelopeObject(obj map[string]any) (map[string]any, bool) {
+ out := map[string]any{}
+ foundArray := false
+ for k, v := range obj {
+ if compactVerboseObjectFields[k] {
+ continue
+ }
+ if envelopeMetadataArrayKeys[k] {
+ out[k] = v
+ continue
+ }
+ if compacted, ok := compactObjectArrayValue(v); ok {
+ foundArray = true
+ out[k] = compacted
+ continue
+ }
+ if nested, ok := v.(map[string]any); ok && k == "_embedded" {
+ if compacted, ok := compactListEnvelopeObject(nested); ok {
+ foundArray = true
+ out[k] = compacted
+ continue
+ }
+ }
+ out[k] = v
+ }
+ if !foundArray {
+ return nil, false
+ }
+ return out, true
+}
+
+func compactObjectArrayValue(v any) (any, bool) {
+ rawItems, ok := v.([]any)
+ if !ok || len(rawItems) == 0 {
+ return nil, false
+ }
+ items := make([]map[string]any, 0, len(rawItems))
+ for _, raw := range rawItems {
+ item, ok := raw.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ items = append(items, item)
+ }
+ compactedRaw := compactListFields(items)
+ var compacted any
+ if err := json.Unmarshal(compactedRaw, &compacted); err != nil {
+ return nil, false
+ }
+ return compacted, true
+}
+
+// printCSV renders JSON arrays as CSV with header row.
+func printCSV(w io.Writer, data json.RawMessage) error {
+ var items []map[string]any
+ if err := json.Unmarshal(data, &items); err != nil || len(items) == 0 {
+ // Single object or empty - just print as JSON
+ fmt.Fprintln(w, string(data))
+ return nil
+ }
+ // Collect all keys for header
+ keySet := map[string]bool{}
+ for _, item := range items {
+ for k := range item {
+ keySet[k] = true
+ }
+ }
+ var keys []string
+ for k := range keySet {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ // Header
+ fmt.Fprintln(w, strings.Join(keys, ","))
+ // Rows
+ for _, item := range items {
+ var vals []string
+ for _, k := range keys {
+ v := item[k]
+ if v == nil {
+ vals = append(vals, "")
+ } else {
+ var s string
+ if f, ok := v.(float64); ok {
+ s = strconv.FormatFloat(f, 'f', -1, 64)
+ } else {
+ s = fmt.Sprintf("%v", v)
+ }
+ if strings.ContainsAny(s, ",\"\n") {
+ s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+ }
+ vals = append(vals, s)
+ }
+ }
+ fmt.Fprintln(w, strings.Join(vals, ","))
+ }
+ return nil
+}
+
+// printPlain renders JSON arrays as tab-separated text with a header row.
+func printPlain(w io.Writer, data json.RawMessage) error {
+ var items []map[string]any
+ if err := json.Unmarshal(data, &items); err != nil {
+ // Single object - just print as JSON
+ fmt.Fprintln(w, string(data))
+ return nil
+ }
+ if len(items) == 0 {
+ return nil
+ }
+ keySet := map[string]bool{}
+ for _, item := range items {
+ for k := range item {
+ keySet[k] = true
+ }
+ }
+ var keys []string
+ for k := range keySet {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ fmt.Fprintln(w, strings.Join(keys, "\t"))
+ for _, item := range items {
+ var vals []string
+ for _, k := range keys {
+ vals = append(vals, plainCellValue(item[k]))
+ }
+ fmt.Fprintln(w, strings.Join(vals, "\t"))
+ }
+ return nil
+}
+
+func plainCellValue(v any) string {
+ if v == nil {
+ return ""
+ }
+ var s string
+ if f, ok := v.(float64); ok {
+ s = strconv.FormatFloat(f, 'f', -1, 64)
+ } else {
+ s = fmt.Sprintf("%v", v)
+ }
+ s = strings.ReplaceAll(s, "\t", " ")
+ s = strings.ReplaceAll(s, "\r\n", " ")
+ s = strings.ReplaceAll(s, "\n", " ")
+ s = strings.ReplaceAll(s, "\r", " ")
+ return s
+}
+
+// printOutput auto-detects arrays and renders as tables, or prints raw JSON for objects.
+func printOutput(w io.Writer, data json.RawMessage, asJSON bool) error {
+ if !asJSON && !isTerminal(w) {
+ asJSON = true
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(data)
+ }
+
+ // Try to detect if response is an array
+ var items []map[string]any
+ if err := json.Unmarshal(data, &items); err == nil && len(items) > 0 {
+ if err := printAutoTable(w, items); err != nil {
+ return err
+ }
+ // Agent-friendly: show count and suggest narrowing when results are large
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+
+ // Single object - pretty print
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err == nil {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(obj)
+ }
+
+ // Fallback: print raw
+ fmt.Fprintln(w, string(data))
+ return nil
+}
+
+// levenshteinDistance computes the edit distance between two strings using a two-row DP approach.
+func levenshteinDistance(a, b string) int {
+ if len(a) == 0 {
+ return len(b)
+ }
+ if len(b) == 0 {
+ return len(a)
+ }
+ if len(a) < len(b) {
+ a, b = b, a
+ }
+ prev := make([]int, len(b)+1)
+ curr := make([]int, len(b)+1)
+ for j := range prev {
+ prev[j] = j
+ }
+ for i := 1; i <= len(a); i++ {
+ curr[0] = i
+ for j := 1; j <= len(b); j++ {
+ cost := 1
+ if a[i-1] == b[j-1] {
+ cost = 0
+ }
+ ins := curr[j-1] + 1
+ del := prev[j] + 1
+ sub := prev[j-1] + cost
+ min := ins
+ if del < min {
+ min = del
+ }
+ if sub < min {
+ min = sub
+ }
+ curr[j] = min
+ }
+ prev, curr = curr, prev
+ }
+ return prev[len(b)]
+}
+
+// suggestFlag returns the closest known flag name to the unknown string, or "" if none is close enough.
+func suggestFlag(unknown string, cmd *cobra.Command) string {
+ unknown = strings.TrimLeft(unknown, "-")
+ best := ""
+ bestDist := 4 // only consider distance <= 3
+ check := func(name string) {
+ d := levenshteinDistance(unknown, name)
+ if d < bestDist && d*5 <= len(unknown)*2 {
+ bestDist = d
+ best = name
+ }
+ }
+ cmd.Flags().VisitAll(func(f *pflag.Flag) {
+ check(f.Name)
+ })
+ cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
+ check(f.Name)
+ })
+ return best
+}
+
+// wantsHumanTable returns true when output should be a human-friendly table.
+// Smart default: terminal=table, pipe=JSON.
+// - Human in terminal: isTerminal()=true → table
+// - --human-friendly: force human output, even when stdout is piped
+// - Claude Code/Codex bash tool: stdout piped → JSON
+// - --json/--csv/--compact/--agent: machine format → JSON
+func wantsHumanTable(w io.Writer, flags *rootFlags) bool {
+ if wantsMachineOutput(flags) {
+ return false
+ }
+ if humanFriendly {
+ return true
+ }
+ return isTerminal(w)
+}
+
+func wantsMachineOutput(flags *rootFlags) bool {
+ return flags.asJSON || flags.csv || flags.compact || flags.quiet || flags.plain || flags.selectFields != ""
+}
+
+func printAutoTable(w io.Writer, items []map[string]any) error {
+ if len(items) == 0 {
+ return nil
+ }
+
+ // Count scalar vs complex fields to decide format
+ scalarCount := 0
+ for _, v := range items[0] {
+ if isCompactScalar(v) {
+ scalarCount++
+ }
+ }
+
+ // Use sectional/card layout for complex items (many fields or nested data)
+ if len(items[0]) > 8 || scalarCount < len(items[0])-2 {
+ return printAutoCards(w, items)
+ }
+
+ headers := prioritizeHeaders(items[0])
+
+ // Limit to 6 columns max for readability
+ if len(headers) > 6 {
+ headers = headers[:6]
+ }
+
+ // Build rows
+ rows := make([][]string, 0, len(items))
+ for _, item := range items {
+ row := make([]string, len(headers))
+ for i, h := range headers {
+ row[i] = formatCellValue(item[h])
+ }
+ rows = append(rows, row)
+ }
+
+ // Print with tab alignment using tabwriter
+ tw := newTabWriter(w)
+ upperHeaders := make([]string, len(headers))
+ for i, h := range headers {
+ upperHeaders[i] = bold(strings.ToUpper(h))
+ }
+
+ fmt.Fprintln(tw, strings.Join(upperHeaders, "\t"))
+ for _, row := range rows {
+ fmt.Fprintln(tw, strings.Join(row, "\t"))
+ }
+ return tw.Flush()
+}
+
+// prioritizeHeaders orders scalar fields by importance for table display.
+func prioritizeHeaders(item map[string]any) []string {
+ return prioritizeFields(item, false)
+}
+
+// prioritizeAllHeaders orders all fields (including arrays) by importance for card display.
+func prioritizeAllHeaders(item map[string]any) []string {
+ return prioritizeFields(item, true)
+}
+
+// prioritizeFields orders fields by importance: identity → temporal → status → other.
+// When includeComplex is true, arrays and objects are included (for card layout).
+//
+// Uses exact-or-suffix matching to avoid false positives: "name" matches "Name" and
+// "UserName" but not "BuildingName" (because "Building" is not a known prefix that
+// indicates identity). The field is split on camelCase/snake_case boundaries and the
+// LAST segment is matched against patterns.
+func prioritizeFields(item map[string]any, includeComplex bool) []string {
+ // Priority tiers — matched against the last segment of the field name.
+ // "OrderDate" → last segment "date" → tier 1 (temporal).
+ // "BuildingName" → last segment "name" → tier 0... but we want to avoid this.
+ // Solution: exact match on the full lowered name OR suffix segment match,
+ // with a penalty for compound names that have a non-identity prefix.
+ type pattern struct {
+ word string
+ tier int
+ }
+ // Exact matches (full field name, case-insensitive) — highest confidence
+ exactMatches := map[string]int{
+ "id": 0, "name": 0, "title": 0, "slug": 0, "key": 0,
+ "date": 1, "created": 1, "updated": 1, "createdat": 1, "updatedat": 1,
+ "status": 2, "state": 2, "statuscode": 2,
+ "summary": 3, "description": 3, "price": 3, "amount": 3, "total": 3,
+ "cost": 3, "points": 3, "score": 3,
+ "type": 4, "kind": 4, "category": 4, "email": 4, "phone": 4, "url": 4,
+ }
+ // Suffix patterns — match when the field ends with this word (after splitting)
+ suffixMatches := map[string]int{
+ "id": 0, "name": 0, "title": 0,
+ "date": 1, "time": 1,
+ "status": 2, "state": 2, "code": 2,
+ "price": 3, "amount": 3, "total": 3, "cost": 3,
+ "summary": 3, "description": 3, "points": 3, "score": 3,
+ "type": 4, "kind": 4, "category": 4, "method": 4,
+ }
+
+ numTiers := 5
+
+ type scored struct {
+ name string
+ tier int
+ index int
+ }
+
+ var all []scored
+ idx := 0
+ for k, v := range item {
+ if !includeComplex {
+ switch v.(type) {
+ case []any, map[string]any:
+ continue
+ }
+ }
+ // Skip values that won't render usefully in cards
+ if includeComplex {
+ formatted := formatCellValue(v)
+ if formatted == "" {
+ continue
+ }
+ }
+
+ tier := numTiers // default: unclassified
+ lower := strings.ToLower(k)
+
+ // 1. Exact match on full field name
+ if t, ok := exactMatches[lower]; ok {
+ tier = t
+ } else {
+ // 2. Split camelCase into segments and match the last one
+ segments := splitCamelCase(lower)
+ if len(segments) > 0 {
+ lastSeg := segments[len(segments)-1]
+ if t, ok := suffixMatches[lastSeg]; ok {
+ // Compound names with identity suffixes (BuildingName, TipTime)
+ // get demoted one tier because the prefix dilutes the signal
+ if len(segments) > 1 {
+ tier = t + 1
+ } else {
+ tier = t
+ }
+ }
+ }
+ }
+
+ // Demote booleans to last
+ if _, ok := v.(bool); ok && tier >= numTiers {
+ tier = numTiers + 1
+ }
+ all = append(all, scored{name: k, tier: tier, index: idx})
+ idx++
+ }
+
+ sort.Slice(all, func(i, j int) bool {
+ if all[i].tier != all[j].tier {
+ return all[i].tier < all[j].tier
+ }
+ return all[i].index < all[j].index
+ })
+
+ headers := make([]string, len(all))
+ for i, s := range all {
+ headers[i] = s.name
+ }
+ return headers
+}
+
+// splitCamelCase splits "OrderDate" → ["order", "date"], "statusCode" → ["status", "code"],
+// "page_size" → ["page", "size"].
+func splitCamelCase(s string) []string {
+ var segments []string
+ var current strings.Builder
+ runes := []rune(s)
+ for i, r := range runes {
+ if r == '_' || r == '-' {
+ if current.Len() > 0 {
+ segments = append(segments, current.String())
+ current.Reset()
+ }
+ continue
+ }
+ if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+ if current.Len() > 0 {
+ segments = append(segments, current.String())
+ current.Reset()
+ }
+ }
+ current.WriteRune(unicode.ToLower(r))
+ }
+ if current.Len() > 0 {
+ segments = append(segments, current.String())
+ }
+ return segments
+}
+
+// printAutoCards renders items as labeled cards — one block per item.
+// Used for complex responses with many fields or nested data.
+func printAutoCards(w io.Writer, items []map[string]any) error {
+ headers := prioritizeAllHeaders(items[0])
+
+ // Find the longest header for alignment (from fields we'll actually show)
+ maxLen := 0
+ for _, h := range headers {
+ if len(h) > maxLen {
+ maxLen = len(h)
+ }
+ }
+
+ for i, item := range items {
+ if i > 0 {
+ fmt.Fprintln(w)
+ }
+
+ // Card header: use first priority field as the card title
+ titleVal := formatCellValue(item[headers[0]])
+ if len(headers) > 1 {
+ secondVal := formatCellValue(item[headers[1]])
+ if secondVal != "" {
+ fmt.Fprintf(w, "%s %s — %s\n", bold(strings.ToUpper(headers[0])), titleVal, secondVal)
+ } else {
+ fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+ }
+ } else {
+ fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+ }
+
+ // Remaining fields indented — skip empty, zero, and false values
+ for _, h := range headers[2:] {
+ v := formatCellValue(item[h])
+ if v == "" || v == "false" || v == "0" || v == "[]" || v == "null" {
+ continue
+ }
+ // Multi-line values (nested arrays) start with \n
+ if strings.HasPrefix(v, "\n") {
+ fmt.Fprintf(w, " %s:%s\n", h, v)
+ } else {
+ fmt.Fprintf(w, " %-*s %s\n", maxLen, h+":", v)
+ }
+ }
+ }
+ return nil
+}
+
+func formatCellValue(v any) string {
+ switch val := v.(type) {
+ case string:
+ // Format ISO dates as just the date portion
+ if len(val) >= 19 && val[4] == '-' && val[7] == '-' && val[10] == 'T' {
+ return val[:10]
+ }
+ return truncate(val, 60)
+ case float64:
+ if val == float64(int64(val)) {
+ return fmt.Sprintf("%d", int64(val))
+ }
+ return fmt.Sprintf("%.2f", val)
+ case bool:
+ return fmt.Sprintf("%t", val)
+ case nil:
+ return ""
+ case []any:
+ if len(val) == 0 {
+ return ""
+ }
+ // If array contains objects, format each as a summary line
+ if obj, isObj := val[0].(map[string]any); isObj {
+ _ = obj
+ return formatObjectArray(val)
+ }
+ // Flatten simple arrays into comma-separated string
+ parts := make([]string, 0, len(val))
+ for _, item := range val {
+ if s, ok := item.(string); ok {
+ parts = append(parts, s)
+ } else {
+ b, _ := json.Marshal(item)
+ parts = append(parts, string(b))
+ }
+ }
+ return truncate(strings.Join(parts, ", "), 60)
+ case map[string]any:
+ return formatSingleObject(val)
+ default:
+ b, _ := json.Marshal(val)
+ return truncate(string(b), 60)
+ }
+}
+
+// formatObjectArray renders an array of objects as multi-line summary.
+// Each object is summarized by its most descriptive fields: name/title, qty, size, price.
+func formatObjectArray(items []any) string {
+ var lines []string
+ for _, raw := range items {
+ obj, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ lines = append(lines, formatObjectSummary(obj))
+ }
+ if len(lines) == 0 {
+ return ""
+ }
+ // Multi-line: newline-prefixed so the card renderer can indent
+ return "\n" + strings.Join(lines, "\n")
+}
+
+// formatObjectSummary extracts the most useful fields from an object into a one-line summary.
+// Looks for: qty/count → name/title → size → price, in that order.
+func formatObjectSummary(obj map[string]any) string {
+ var parts []string
+
+ // Quantity
+ qty := findField(obj, "qty", "count", "quantity")
+ if qty != "" && qty != "1" && qty != "0" {
+ parts = append(parts, qty+"x")
+ } else if qty == "1" {
+ parts = append(parts, "1x")
+ }
+
+ // Name — check nested objects too (e.g., Side1.Name)
+ name := findField(obj, "name", "title", "label", "description")
+ if name == "" {
+ // Check nested objects for name
+ for _, key := range []string{"Side1", "side1", "Item", "item", "Product", "product"} {
+ if nested, ok := obj[key].(map[string]any); ok {
+ name = findField(nested, "name", "title", "label")
+ if name != "" {
+ break
+ }
+ }
+ }
+ }
+ if name != "" {
+ parts = append(parts, name)
+ }
+
+ // Size
+ size := findField(obj, "sizename", "size_name")
+ if size == "" {
+ size = findField(obj, "catname", "cat_name", "category")
+ }
+ if size != "" {
+ parts = append(parts, "—")
+ parts = append(parts, size)
+ }
+
+ // Price
+ price := findField(obj, "extprice", "price", "amount", "total")
+ if price != "" && price != "0" {
+ parts = append(parts, fmt.Sprintf("($%s)", price))
+ }
+
+ if len(parts) == 0 {
+ // Fallback: JSON summary
+ b, _ := json.Marshal(obj)
+ return truncate(string(b), 80)
+ }
+ return " " + strings.Join(parts, " ")
+}
+
+// formatSingleObject renders a single object by its most descriptive fields.
+func formatSingleObject(obj map[string]any) string {
+ name := findField(obj, "name", "title", "label", "description")
+ if name != "" {
+ return name
+ }
+ id := findField(obj, "id", "key", "code")
+ if id != "" {
+ return id
+ }
+ return ""
+}
+
+// findField searches an object for a field name (case-insensitive) and returns its formatted value.
+func findField(obj map[string]any, names ...string) string {
+ for _, name := range names {
+ for k, v := range obj {
+ if strings.EqualFold(k, name) {
+ return formatCellValue(v)
+ }
+ }
+ }
+ return ""
+}
+
+// DataProvenance describes where data came from and when it was last synced.
+type DataProvenance struct {
+ Source string `json:"source"` // "live" or "local"
+ SyncedAt *time.Time `json:"synced_at,omitempty"` // when local data was last synced
+ Reason string `json:"reason,omitempty"` // why local was used: "user_requested", "api_unreachable", "no_search_endpoint"
+ ResourceType string `json:"resource_type,omitempty"` // which resource type was queried
+ Freshness any `json:"freshness,omitempty"` // optional machine-owned freshness metadata for covered command paths
+}
+
+// printProvenance writes a one-line provenance message to stderr for TTY users.
+// Suppressed when stdout is piped or redirected — the JSON response envelope
+// already carries meta.source, so the stderr line is redundant and becomes
+// noise in agent flows that merge stderr into stdout.
+func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
+ if !isTerminal(cmd.OutOrStdout()) {
+ return
+ }
+ if prov.Source == "live" {
+ fmt.Fprintf(cmd.ErrOrStderr(), "%d results (live)\n", count)
+ return
+ }
+ age := "unknown"
+ if prov.SyncedAt != nil {
+ d := time.Since(*prov.SyncedAt)
+ switch {
+ case d < time.Minute:
+ age = "just now"
+ case d < time.Hour:
+ age = fmt.Sprintf("%d minutes ago", int(d.Minutes()))
+ case d < 24*time.Hour:
+ age = fmt.Sprintf("%d hours ago", int(d.Hours()))
+ default:
+ age = fmt.Sprintf("%d days ago", int(d.Hours()/24))
+ }
+ }
+ prefix := ""
+ if prov.Reason == "api_unreachable" {
+ prefix = "API unreachable. "
+ }
+ fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
+}
+
+func nonJSONPayloadError(data json.RawMessage) error {
+ trimmed := bytes.TrimSpace(data)
+ if len(trimmed) > 0 && trimmed[0] == '<' {
+ return authErr(fmt.Errorf("not authenticated or session expired; API returned HTML instead of JSON. " + "Run 'human-goat-pp-cli auth login --chrome' to refresh browser-session credentials."))
+ }
+ if len(trimmed) == 0 {
+ return apiErr(fmt.Errorf("API returned an empty response body; expected JSON"))
+ }
+ return apiErr(fmt.Errorf("API returned a non-JSON response; expected JSON"))
+}
+
+func assertLiveJSONBody(data json.RawMessage) error {
+ if json.Valid(data) {
+ return nil
+ }
+ return nonJSONPayloadError(data)
+}
+
+// wrapWithProvenance wraps response data in a provenance envelope:
+// {"results": ..., "meta": {...}}. Single-key array envelopes from the API
+// (e.g. {"results": [...]}, {"data": [...]}) are unwrapped first so the
+// output shape is the same regardless of the API's wrapper key.
+func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
+ meta := map[string]any{"source": prov.Source}
+ if prov.SyncedAt != nil {
+ meta["synced_at"] = prov.SyncedAt.UTC().Format(time.RFC3339)
+ }
+ if prov.Reason != "" {
+ meta["reason"] = prov.Reason
+ }
+ if prov.ResourceType != "" {
+ meta["resource_type"] = prov.ResourceType
+ }
+ if prov.Freshness != nil {
+ meta["freshness"] = prov.Freshness
+ }
+ var results any
+ if json.Valid(data) {
+ results = json.RawMessage(unwrapSingleKeyArray(data))
+ } else {
+ return nil, nonJSONPayloadError(data)
+ }
+ envelope := map[string]any{
+ "results": results,
+ "meta": meta,
+ }
+ return json.Marshal(envelope)
+}
+
+// defaultDBPath returns the canonical path for the local SQLite database.
+// The resolver already knows the app name on the happy path; name is only
+// used for the conservative fallback when the resolved data directory fails.
+func defaultDBPath(name string) string {
+ dir, err := cliutil.DataDir()
+ if err != nil {
+ if home, homeErr := os.UserHomeDir(); homeErr == nil {
+ return filepath.Join(home, ".local", "share", name, "data.db")
+ }
+ return "data.db"
+ }
+ return filepath.Join(dir, "data.db")
+}
diff --git a/library/productivity/human-goat/internal/cli/hire.go b/library/productivity/human-goat/internal/cli/hire.go
new file mode 100644
index 0000000000..5c09ce3f20
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/hire.go
@@ -0,0 +1,616 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// pp:data-source live
+// Novel command scaffold. Implement the RunE body before shipping.
+// generate --force preserves implemented bodies; untouched TODO scaffolds may refresh.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/pricing"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/taskrabbit"
+
+ "github.com/spf13/cobra"
+)
+
+func newNovelHireCmd(flags *rootFlags) *cobra.Command {
+ var flagOn string
+ var flagMinRating string
+ var flagMaxTotal string
+ var flagLat float64
+ var flagLng float64
+ var flagState string
+ var flagAddressJSON string
+ var flagEndAddressJSON string
+ var flagNote string
+
+ cmd := &cobra.Command{
+ Use: "hire ",
+ Short: "Say the job and the date; goat searches, ranks by review quality and honest all-in price",
+ Example: "human-goat-pp-cli hire \"help moving\" --on 2026-07-11 --lat 37.7749 --lng -122.4194 --state CA --min-rating 4.8 --max-total 200 --dry-run --agent",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && !commandHasChangedFlags(cmd) {
+ return cmd.Help()
+ }
+ query := strings.TrimSpace(strings.Join(args, " "))
+ if query == "" {
+ return usageErr(fmt.Errorf("missing job-query"))
+ }
+
+ primaryAddress, hasPrimaryAddress, err := parseTaskRabbitAddressFlag("--address-json", flagAddressJSON)
+ if err != nil {
+ return usageErr(err)
+ }
+ endAddress, hasEndAddress, err := parseTaskRabbitAddressFlag("--end-address-json", flagEndAddressJSON)
+ if err != nil {
+ return usageErr(err)
+ }
+ realCommit := !dryRunOK(flags) && !cliutil.IsVerifyEnv() && !cliutil.IsDogfoodEnv()
+ if realCommit && !hasPrimaryAddress {
+ return usageErr(fmt.Errorf("--address-json is required to book (a geocoded address incl metro_id); use --dry-run to preview without it"))
+ }
+ if hasPrimaryAddress {
+ if err := validateTaskRabbitAddress("--address-json", *primaryAddress); err != nil {
+ return usageErr(err)
+ }
+ }
+ if hasEndAddress {
+ if err := validateTaskRabbitAddress("--end-address-json", *endAddress); err != nil {
+ return usageErr(err)
+ }
+ }
+
+ lat := flagLat
+ lng := flagLng
+ if hasPrimaryAddress {
+ lat = primaryAddress.Lat
+ lng = primaryAddress.Lng
+ }
+ if !hasPrimaryAddress && (!cmd.Flags().Changed("lat") || !cmd.Flags().Changed("lng")) {
+ return usageErr(fmt.Errorf("pass --lat and --lng for your location"))
+ }
+
+ date, err := parseOnDate(flagOn)
+ if err != nil {
+ return usageErr(err)
+ }
+ minRating, err := parseOptionalFloatFlag("--min-rating", flagMinRating)
+ if err != nil {
+ return usageErr(err)
+ }
+ maxTotal, err := parseOptionalFloatFlag("--max-total", flagMaxTotal)
+ if err != nil {
+ return usageErr(err)
+ }
+ if minRating < 0 {
+ return usageErr(fmt.Errorf("--min-rating must be non-negative"))
+ }
+ if maxTotal < 0 {
+ return usageErr(fmt.Errorf("--max-total must be non-negative"))
+ }
+
+ ctx, cancel := boundCtx(cmd.Context(), flags)
+ defer cancel()
+
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ if dryRunOK(flags) {
+ c.DryRun = false
+ }
+ category, err := resolveTaskRabbitCategory(ctx, c, query)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+
+ tr := taskrabbit.New(c)
+ taskers, _, recommendationID, err := tr.Recommendations(ctx, taskrabbit.BuildRecommendationsInput(category.CategoryID, category.DefaultTemplateID, lat, lng, []string{date}))
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+
+ best, ok := selectHireTasker(taskers, minRating, flagState)
+ if !ok {
+ return fmt.Errorf("no Tasker matches --min-rating")
+ }
+
+ inviteeID, notes := hireInviteeID(best)
+ availableDates, err := tr.Schedule(ctx, category.CategoryID, inviteeID, "en-US", lat, lng, category.DefaultTemplateID)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ slotDate, slotLabel, durationSeconds, offsetSeconds, availabilityNote := firstTaskRabbitSlot(availableDates, date)
+ if availabilityNote != "" {
+ notes = append(notes, availabilityNote)
+ }
+ // firstTaskRabbitSlot silently falls back to another day when the
+ // requested date has no availability; warn in the preview too so the
+ // dry-run summary never hides a date substitution before checkout.
+ if slotDate != "" && date != "" && slotDate != date {
+ notes = append(notes, fmt.Sprintf("requested %s was unavailable; would use %s instead", date, slotDate))
+ }
+
+ allInHourly := pricing.AllIn(best.PosterHourlyRateCents, flagState).AllInCents
+ // Estimate the booking total from the actual slot duration rather than a
+ // flat 2-hour guess, so the spend cap does not reject in-budget bookings.
+ // Use float hours so a fractional slot (e.g. 1.5h) is not truncated by
+ // integer division, floor at 1 hour, honor the Tasker's 2-hour minimum,
+ // and round the estimate UP so the cap never underestimates the charge.
+ estHours := float64(durationSeconds) / 3600.0
+ if estHours < 1 {
+ estHours = 1
+ }
+ if taskerRequiresTwoHourMinimum(best) && estHours < 2 {
+ estHours = 2
+ }
+ totalCents := int(math.Ceil(float64(allInHourly) * estHours))
+ withinCap := maxTotal == 0 || float64(totalCents)/100.0 <= maxTotal
+ if maxTotal > 0 && !withinCap {
+ fmt.Fprintf(cmd.ErrOrStderr(), "refusing: all-in total %s exceeds cap %s\n", pricing.FormatCents(totalCents), formatDollarCap(maxTotal))
+ return fmt.Errorf("spend cap exceeded")
+ }
+
+ summary := hireConfirmSummary{
+ Tasker: taskerDisplayName(best),
+ AllInHourly: pricing.FormatCents(allInHourly),
+ AllInTotalEstimate: pricing.FormatCents(totalCents),
+ Rating: best.RabbitRating,
+ Date: slotDate,
+ SlotLabel: slotLabel,
+ WithinCap: withinCap,
+ Note: strings.Join(notes, "; "),
+ }
+ if dryRunOK(flags) || cliutil.IsVerifyEnv() || cliutil.IsDogfoodEnv() {
+ return printHireConfirmSummary(cmd, flags, summary)
+ }
+
+ // firstTaskRabbitSlot only sets availabilityNote when no day had any
+ // open slot (it then fabricates a 1-hour placeholder). Never commit a
+ // real checkout against a slot the Schedule API did not actually offer.
+ if availabilityNote != "" {
+ return fmt.Errorf("no available slot for %q around %s; not booking (try a different date)", query, date)
+ }
+
+ if recommendationID == "" {
+ return fmt.Errorf("TaskRabbit recommendations response did not include recommendation_id")
+ }
+ if best.UserIDInt == 0 {
+ return fmt.Errorf("TaskRabbit recommendation for %s did not include numeric user_id", taskerDisplayName(best))
+ }
+
+ token, err := csrfToken(ctx, flags)
+ if err != nil {
+ return classifyAPIError(fmt.Errorf("hire TaskRabbit booking: get CSRF token: %w", err), flags)
+ }
+
+ categoryName := taskRabbitCategoryName(category)
+ input := taskrabbit.HireInput{
+ Source: "recommendation",
+ JobType: "Template",
+ FixedRate: false,
+ SecondsBetween: "0",
+ ShownCancellationPolicy: true,
+ TaskTemplateID: category.DefaultTemplateID,
+ CategoryID: category.CategoryID,
+ CategoryName: categoryName,
+ Title: categoryName,
+ MarketingGroupID: 15,
+ FunnelID: taskrabbit.SynthFunnelID(),
+ SessionID: taskrabbit.SynthSessionID(),
+ RecommendationID: recommendationID,
+ InviteeID: best.UserIDInt,
+ RabbitID: best.UserIDInt,
+ PosterHourlyRateCents: best.PosterHourlyRateCents,
+ JobDraftGUID: "",
+ FormReferrer: "",
+ JobSize: "small",
+ Description: hireDescription(flagNote, query),
+ Schedule: taskrabbit.DateSpec{
+ Date: slotDate,
+ DurationSeconds: durationSeconds,
+ OffsetSeconds: offsetSeconds,
+ },
+ Address: *primaryAddress,
+ }
+ if hasEndAddress {
+ input.SecondaryLocation = endAddress
+ }
+
+ body, err := tr.Hire(ctx, input, token)
+ if err != nil {
+ return classifyAPIError(fmt.Errorf("hire TaskRabbit booking: %w", err), flags)
+ }
+
+ // Prefer a confirmed charge from the hire response; fall back to the
+ // pre-checkout estimate and say so, so all_in_total is never presented
+ // as a settled amount when it is only an estimate.
+ allInTotal := pricing.FormatCents(totalCents)
+ totalConfirmed := false
+ if confirmedCents, ok := hireConfirmedTotalCents(body); ok {
+ allInTotal = pricing.FormatCents(confirmedCents)
+ totalConfirmed = true
+ }
+ booked := hireBookedResult{
+ Booked: true,
+ Tasker: taskerDisplayName(best),
+ AllInTotal: allInTotal,
+ TotalConfirmed: totalConfirmed,
+ JobID: hireJobID(body),
+ RequestedDate: date,
+ Date: slotDate,
+ SlotLabel: slotLabel,
+ }
+ if !totalConfirmed {
+ booked.Note = joinNotes(booked.Note, "all_in_total is a pre-checkout estimate; confirm the final charge on the TaskRabbit invoice")
+ }
+ // Surface a date change loudly: firstTaskRabbitSlot falls back to
+ // another day when the requested date has no availability, and a
+ // silent booking on an unrequested date is exactly what an
+ // autonomous checkout must never hide.
+ if slotDate != "" && date != "" && slotDate != date {
+ booked.Note = fmt.Sprintf("requested %s was unavailable; booked %s instead", date, slotDate)
+ }
+ return printHireBookedResult(cmd, flags, booked)
+ },
+ }
+ cmd.Flags().StringVar(&flagOn, "on", "", "Date to book: YYYY-MM-DD, today, tomorrow, or weekday")
+ cmd.Flags().StringVar(&flagMinRating, "min-rating", "", "Minimum Tasker rating")
+ cmd.Flags().StringVar(&flagMaxTotal, "max-total", "", "Maximum all-in booking total in dollars (0 for no cap)")
+ cmd.Flags().Float64Var(&flagLat, "lat", 0, "Latitude for TaskRabbit recommendations")
+ cmd.Flags().Float64Var(&flagLng, "lng", 0, "Longitude for TaskRabbit recommendations")
+ cmd.Flags().StringVar(&flagState, "state", "", "State for CA/MA service-fee-only pricing rule")
+ cmd.Flags().StringVar(&flagAddressJSON, "address-json", "", "Geocoded TaskRabbit address JSON for checkout")
+ cmd.Flags().StringVar(&flagEndAddressJSON, "end-address-json", "", "Optional secondary TaskRabbit address JSON for checkout")
+ cmd.Flags().StringVar(&flagNote, "note", "", "Task description sent during checkout")
+ return cmd
+}
+
+type hireConfirmSummary struct {
+ Tasker string `json:"tasker"`
+ AllInHourly string `json:"all_in_hourly"`
+ AllInTotalEstimate string `json:"all_in_total_estimate"`
+ Rating float64 `json:"rating"`
+ Date string `json:"date"`
+ SlotLabel string `json:"slot_label"`
+ WithinCap bool `json:"within_cap"`
+ Note string `json:"note,omitempty"`
+}
+
+type hireBookedResult struct {
+ Booked bool `json:"booked"`
+ Tasker string `json:"tasker"`
+ AllInTotal string `json:"all_in_total"`
+ TotalConfirmed bool `json:"total_confirmed"`
+ JobID string `json:"job_id,omitempty"`
+ RequestedDate string `json:"requested_date,omitempty"`
+ Date string `json:"date,omitempty"`
+ SlotLabel string `json:"slot_label,omitempty"`
+ Note string `json:"note,omitempty"`
+}
+
+func parseOptionalFloatFlag(name, value string) (float64, error) {
+ clean := strings.TrimSpace(value)
+ clean = strings.TrimPrefix(clean, "$")
+ if clean == "" {
+ return 0, nil
+ }
+ parsed, err := strconv.ParseFloat(clean, 64)
+ if err != nil {
+ return 0, fmt.Errorf("%s must be a number", name)
+ }
+ return parsed, nil
+}
+
+func parseTaskRabbitAddressFlag(name, value string) (*taskrabbit.Address, bool, error) {
+ clean := strings.TrimSpace(value)
+ if clean == "" {
+ return nil, false, nil
+ }
+ var address taskrabbit.Address
+ if err := json.Unmarshal([]byte(clean), &address); err != nil {
+ return nil, false, fmt.Errorf("%s must be a TaskRabbit address JSON object: %w", name, err)
+ }
+ return &address, true, nil
+}
+
+func validateTaskRabbitAddress(name string, address taskrabbit.Address) error {
+ missing := make([]string, 0)
+ if strings.TrimSpace(address.Address1) == "" {
+ missing = append(missing, "address1")
+ }
+ if strings.TrimSpace(address.Country) == "" {
+ missing = append(missing, "country")
+ }
+ if strings.TrimSpace(address.FormattedAddress) == "" {
+ missing = append(missing, "formatted_address")
+ }
+ if address.Lat == 0 {
+ missing = append(missing, "lat")
+ }
+ if address.Lng == 0 {
+ missing = append(missing, "lng")
+ }
+ if strings.TrimSpace(address.Locality) == "" {
+ missing = append(missing, "locality")
+ }
+ if address.MetroID == 0 {
+ missing = append(missing, "metro_id")
+ }
+ if strings.TrimSpace(address.MetroName) == "" {
+ missing = append(missing, "metro_name")
+ }
+ if strings.TrimSpace(address.PostalCode) == "" {
+ missing = append(missing, "postal_code")
+ }
+ if strings.TrimSpace(address.Region) == "" {
+ missing = append(missing, "region")
+ }
+ if len(missing) > 0 {
+ return fmt.Errorf("%s missing required fields: %s", name, strings.Join(missing, ", "))
+ }
+ return nil
+}
+
+func selectHireTasker(taskers []taskrabbit.Tasker, minRating float64, state string) (taskrabbit.Tasker, bool) {
+ candidates := make([]taskrabbit.Tasker, 0, len(taskers))
+ for _, tasker := range taskers {
+ if minRating > 0 && tasker.RabbitRating < minRating {
+ continue
+ }
+ candidates = append(candidates, tasker)
+ }
+ if len(candidates) == 0 {
+ return taskrabbit.Tasker{}, false
+ }
+ sort.SliceStable(candidates, func(i, j int) bool {
+ left := pricing.AllIn(candidates[i].PosterHourlyRateCents, state).AllInCents
+ right := pricing.AllIn(candidates[j].PosterHourlyRateCents, state).AllInCents
+ if left != right {
+ return left < right
+ }
+ return candidates[i].RabbitRating > candidates[j].RabbitRating
+ })
+ return candidates[0], true
+}
+
+func hireInviteeID(tasker taskrabbit.Tasker) (int, []string) {
+ if tasker.UserIDInt > 0 {
+ return tasker.UserIDInt, make([]string, 0)
+ }
+ return taskerInviteeID(tasker.ID)
+}
+
+func taskerInviteeID(id string) (int, []string) {
+ clean := strings.TrimSpace(id)
+ if parsed, err := strconv.Atoi(clean); err == nil {
+ return parsed, make([]string, 0)
+ }
+ // Recommendation ids look like "profile_28810417"; the invitee id is the
+ // trailing numeric run.
+ digits := clean
+ if i := strings.LastIndexByte(clean, '_'); i >= 0 {
+ digits = clean[i+1:]
+ }
+ if parsed, err := strconv.Atoi(digits); err == nil {
+ return parsed, make([]string, 0)
+ }
+ return 0, []string{fmt.Sprintf("tasker id %q has no numeric invitee id; using invitee_id=0", clean)}
+}
+
+// taskerRequiresTwoHourMinimum reports whether the recommendation's
+// two_hour_minimum_required_display field indicates a 2-hour booking minimum.
+func taskerRequiresTwoHourMinimum(t taskrabbit.Tasker) bool {
+ switch v := t.TwoHourMinimum.(type) {
+ case bool:
+ return v
+ case string:
+ s := strings.TrimSpace(strings.ToLower(v))
+ return s != "" && s != "false" && s != "0" && s != "no"
+ case float64:
+ return v != 0
+ case map[string]any:
+ return len(v) > 0
+ default:
+ return false
+ }
+}
+
+func firstTaskRabbitSlot(availableDates []taskrabbit.AvailableDate, requestedDate string) (string, string, int, int, string) {
+ for _, day := range availableDates {
+ if day.Date == requestedDate && len(day.Slots) > 0 {
+ return day.Date, day.Slots[0].SelectLabel, slotDurationSeconds(day.Slots[0]), day.Slots[0].OffsetSeconds, ""
+ }
+ }
+ for _, day := range availableDates {
+ if len(day.Slots) > 0 {
+ return day.Date, day.Slots[0].SelectLabel, slotDurationSeconds(day.Slots[0]), day.Slots[0].OffsetSeconds, ""
+ }
+ }
+ return requestedDate, "", 3600, 0, fmt.Sprintf("no availability on %s", requestedDate)
+}
+
+func slotDurationSeconds(slot taskrabbit.Slot) int {
+ if slot.DurationSeconds > 0 {
+ return slot.DurationSeconds
+ }
+ return 3600
+}
+
+func taskRabbitCategoryName(category taskrabbitCategoryMatch) string {
+ name := strings.TrimSpace(category.CategoryName)
+ if name != "" {
+ return name
+ }
+ return strings.TrimSpace(category.Title)
+}
+
+func hireDescription(note, query string) string {
+ description := strings.TrimSpace(note)
+ if description != "" {
+ return description
+ }
+ return fmt.Sprintf("Need help with %s.", strings.TrimSpace(query))
+}
+
+// hireConfirmedTotalCents extracts a settled charge from the hire response when
+// TaskRabbit returns one, so the booked result can report a confirmed total
+// rather than the pre-checkout estimate.
+func hireConfirmedTotalCents(raw json.RawMessage) (int, bool) {
+ if len(raw) == 0 {
+ return 0, false
+ }
+ var decoded any
+ if err := json.Unmarshal(raw, &decoded); err != nil {
+ return 0, false
+ }
+ for _, key := range []string{"all_in_total_cents", "total_charged_cents", "charge_total_cents", "invoice_total_cents", "total_cents", "amount_cents"} {
+ if cents, ok := findIntFieldByKey(decoded, key); ok && cents > 0 {
+ return cents, true
+ }
+ }
+ return 0, false
+}
+
+// findIntFieldByKey recursively finds the first integer value for key.
+func findIntFieldByKey(value any, key string) (int, bool) {
+ switch v := value.(type) {
+ case map[string]any:
+ if item, ok := v[key]; ok {
+ switch n := item.(type) {
+ case float64:
+ return int(n), true
+ case json.Number:
+ if i, err := n.Int64(); err == nil {
+ return int(i), true
+ }
+ }
+ }
+ for _, item := range v {
+ if i, ok := findIntFieldByKey(item, key); ok {
+ return i, true
+ }
+ }
+ case []any:
+ for _, item := range v {
+ if i, ok := findIntFieldByKey(item, key); ok {
+ return i, true
+ }
+ }
+ }
+ return 0, false
+}
+
+func hireJobID(raw json.RawMessage) string {
+ keys := []string{"job_id", "jobId", "booking_id", "bookingId", "task_id", "taskId", "id"}
+ return findJSONID(raw, keys)
+}
+
+func findJSONID(raw json.RawMessage, keys []string) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var obj map[string]json.RawMessage
+ if json.Unmarshal(raw, &obj) == nil && obj != nil {
+ for _, key := range keys {
+ if value, ok := obj[key]; ok {
+ if id := jsonIDScalar(value); id != "" {
+ return id
+ }
+ }
+ }
+ for _, value := range obj {
+ if id := findJSONID(value, keys); id != "" {
+ return id
+ }
+ }
+ return ""
+ }
+ var arr []json.RawMessage
+ if json.Unmarshal(raw, &arr) == nil {
+ for _, value := range arr {
+ if id := findJSONID(value, keys); id != "" {
+ return id
+ }
+ }
+ }
+ return ""
+}
+
+func jsonIDScalar(raw json.RawMessage) string {
+ decoder := json.NewDecoder(strings.NewReader(string(raw)))
+ decoder.UseNumber()
+ var value any
+ if err := decoder.Decode(&value); err != nil {
+ return ""
+ }
+ switch v := value.(type) {
+ case string:
+ return strings.TrimSpace(v)
+ case json.Number:
+ return v.String()
+ default:
+ return ""
+ }
+}
+
+func formatDollarCap(value float64) string {
+ return fmt.Sprintf("$%.2f", value)
+}
+
+func printHireConfirmSummary(cmd *cobra.Command, flags *rootFlags, summary hireConfirmSummary) error {
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), summary, flags)
+ }
+ rows := [][]string{
+ {"Tasker", summary.Tasker},
+ {"All-in hourly", summary.AllInHourly},
+ {"All-in total estimate", summary.AllInTotalEstimate},
+ {"Rating", fmt.Sprintf("%.2f", summary.Rating)},
+ {"Date", summary.Date},
+ {"Slot", summary.SlotLabel},
+ {"Within cap", fmt.Sprintf("%t", summary.WithinCap)},
+ }
+ if summary.Note != "" {
+ rows = append(rows, []string{"Note", summary.Note})
+ }
+ return flags.printTable(cmd, []string{"FIELD", "VALUE"}, rows)
+}
+
+func printHireBookedResult(cmd *cobra.Command, flags *rootFlags, result hireBookedResult) error {
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), result, flags)
+ }
+ rows := [][]string{
+ {"Booked", fmt.Sprintf("%t", result.Booked)},
+ {"Tasker", result.Tasker},
+ {"All-in total", result.AllInTotal},
+ }
+ if result.AllInTotal != "" {
+ confirmed := "estimate"
+ if result.TotalConfirmed {
+ confirmed = "confirmed"
+ }
+ rows = append(rows, []string{"Total basis", confirmed})
+ }
+ if result.Date != "" {
+ rows = append(rows, []string{"Date", result.Date})
+ }
+ if result.SlotLabel != "" {
+ rows = append(rows, []string{"Slot", result.SlotLabel})
+ }
+ if result.JobID != "" {
+ rows = append(rows, []string{"Job ID", result.JobID})
+ }
+ if result.Note != "" {
+ rows = append(rows, []string{"Note", result.Note})
+ }
+ return flags.printTable(cmd, []string{"FIELD", "VALUE"}, rows)
+}
diff --git a/library/productivity/human-goat/internal/cli/hire_test.go b/library/productivity/human-goat/internal/cli/hire_test.go
new file mode 100644
index 0000000000..4a1f05695b
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/hire_test.go
@@ -0,0 +1,32 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// cli-printing-press: novel-scaffold-test
+// Novel command scaffold tests. Keep the wiring smoke test and add behavior cases as needed.
+
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// TestNovelHireHelpWires smoke-tests that the hire command
+// resolves at runtime and renders useful --help output. Catches wiring
+// regressions (missing AddCommand, panicking RunE on --help, etc.) before
+// review. Keep this smoke test when adding behavior-specific cases.
+func TestNovelHireHelpWires(t *testing.T) {
+ cmd := RootCmd()
+ cmd.SetArgs([]string{"hire", "--help"})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("hire --help error = %v (novel command not wired correctly?)", err)
+ }
+ help := out.String()
+ for _, want := range []string{"Usage:", "hire"} {
+ if !strings.Contains(help, want) {
+ t.Fatalf("hire --help missing %q in output:\n%s", want, help)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/learn_init.go b/library/productivity/human-goat/internal/cli/learn_init.go
new file mode 100644
index 0000000000..b00e368b73
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/learn_init.go
@@ -0,0 +1,76 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// learn_init.go is the spec-driven init shim for the self-learning loop.
+// newLearnConfig builds the per-CLI *entities.Config the cobra teach /
+// recall / learnings commands thread through every call site;
+// initLearn seeds the entity_lookups table from the canonical-+-aliases
+// table declared in spec.Learn.EntityLookupSeeds. Both run once at
+// startup; subsequent calls are idempotent (INSERT OR IGNORE on the
+// store side, fresh Config build on the registration side).
+//
+// Soft validation: a runtime regex compile failure (which spec parse
+// already rejects at load time) logs to stderr and continues without
+// the pattern instead of aborting the CLI. Empty Learn config produces
+// a Config with built-in stopwords and a no-op initLearn.
+
+package cli
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "sync"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// newLearnConfig returns the per-CLI entity extractor Config the
+// teach / recall / normalize call sites pass into the learn package.
+// Reads the printed-CLI's spec.Learn.TickerPatterns, Stopwords, and
+// Synonyms; patterns that fail to compile at runtime (defense in
+// depth — spec parse already validated them) log a warning and are
+// skipped. Declared synonyms register on both the returned Config and
+// the store package's write-side normalizer.
+func newLearnConfig() *entities.Config {
+ cfg := entities.NewConfig()
+ return cfg
+}
+
+// initLearn seeds the entity_lookups table from the spec.Learn.
+// EntityLookupSeeds declaration. INSERT OR IGNORE on the store side
+// makes this idempotent — calling it twice produces the same DB state
+// as calling it once. An empty seed map is a no-op (returns nil).
+func initLearn(ctx context.Context, db *sql.DB) error {
+ _ = ctx
+ _ = db
+ return nil
+}
+
+// learnInitOnce gates runLearnInitOnce so the seed pass happens at
+// most once per CLI process. The sync.Once keeps the cost off the
+// hot path for repeat command invocations within an MCP session.
+var learnInitOnce sync.Once
+
+// runLearnInitOnce opens the canonical store path, fires initLearn
+// once per process, and downgrades any failure to a stderr warning.
+// Per the self-learning loop's soft-validation contract, a seed
+// failure must never abort the CLI — the recall path returns the
+// empty envelope if entity_lookups is missing rows, which is the
+// same behavior an opt-out CLI sees.
+func runLearnInitOnce(ctx context.Context) {
+ learnInitOnce.Do(func() {
+ dbPath := defaultDBPath("human-goat-pp-cli")
+ s, err := store.OpenWithContext(ctx, dbPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: learn init: open store: %v\n", err)
+ return
+ }
+ defer s.Close()
+ if err := initLearn(ctx, s.DB()); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: learn init: %v\n", err)
+ }
+ })
+}
diff --git a/library/productivity/human-goat/internal/cli/learn_init_test.go b/library/productivity/human-goat/internal/cli/learn_init_test.go
new file mode 100644
index 0000000000..e965cc1806
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/learn_init_test.go
@@ -0,0 +1,116 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// TestNewLearnConfig_BuildsConfig pins that the emitted init shim
+// returns a usable *entities.Config. Whether the spec declared
+// patterns/stopwords or not, the config must be non-nil so call sites
+// don't need a nil-check.
+func TestNewLearnConfig_BuildsConfig(t *testing.T) {
+ cfg := newLearnConfig()
+ if cfg == nil {
+ t.Fatal("newLearnConfig returned nil")
+ }
+}
+
+// TestInitLearn_IdempotentSeed pins that initLearn can be called
+// twice without changing DB state. The lookups.SeedFromConfig insert
+// path uses INSERT OR IGNORE so the second call must be a no-op on
+// row count.
+func TestInitLearn_IdempotentSeed(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "init.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ defer s.Close()
+
+ if err := initLearn(context.Background(), s.DB()); err != nil {
+ t.Fatalf("first initLearn: %v", err)
+ }
+ firstCount := countLookupRows(t, s)
+
+ if err := initLearn(context.Background(), s.DB()); err != nil {
+ t.Fatalf("second initLearn: %v", err)
+ }
+ secondCount := countLookupRows(t, s)
+
+ if firstCount != secondCount {
+ t.Errorf("initLearn is not idempotent: first=%d second=%d", firstCount, secondCount)
+ }
+}
+
+// TestInitLearn_NilDB pins that passing a nil *sql.DB to a spec with
+// declared seeds surfaces as an error from SeedFromConfig instead of
+// a nil-pointer panic. Specs without seeds short-circuit before the
+// nil-db reaches the lookups package and return nil.
+func TestInitLearn_NilDB(t *testing.T) {
+ // Call with nil db. Behavior depends on whether the spec has
+ // declared any EntityLookupSeeds; both branches must not panic.
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("initLearn panicked on nil db: %v", r)
+ }
+ }()
+ _ = initLearn(context.Background(), nil)
+}
+
+// countLookupRows returns the number of rows in entity_lookups.
+// Returns 0 (with no test failure) when the table doesn't exist —
+// some test specs may not include the learn migration if the spec
+// gate is off, though this file only emits when learn is on.
+func countLookupRows(t *testing.T, s *store.Store) int {
+ t.Helper()
+ var n int
+ row := s.DB().QueryRow("SELECT COUNT(*) FROM entity_lookups")
+ if err := row.Scan(&n); err != nil {
+ t.Fatalf("count entity_lookups: %v", err)
+ }
+ return n
+}
+
+// TestLearnNormalizers_SynonymFoldSymmetry pins the two-normalizer
+// contract: the learn package's family normalization (read side) and
+// the store package's query-pattern normalization (write side) must
+// both fold same-referent phrasings to one key, so a learning taught
+// with phrasing A is always reachable from phrasing B.
+func TestLearnNormalizers_SynonymFoldSymmetry(t *testing.T) {
+ cfg := newLearnConfig()
+ pairs := [][2]string{
+ {"why did Alpha win yesterday", "why did Alpha win last night"},
+ {"report for today", "report for to-day"},
+ {"plans for tonight", "plans for tonite"},
+ }
+ for _, p := range pairs {
+ famA := learn.QueryFamily(learn.Normalize(p[0], cfg))
+ famB := learn.QueryFamily(learn.Normalize(p[1], cfg))
+ if famA == "" || famA != famB {
+ t.Errorf("read-side family mismatch for %q vs %q: %q vs %q", p[0], p[1], famA, famB)
+ }
+ patA := store.NormalizeQuery(p[0])
+ patB := store.NormalizeQuery(p[1])
+ if patA == "" || patA != patB {
+ t.Errorf("write-side pattern mismatch for %q vs %q: %q vs %q", p[0], p[1], patA, patB)
+ }
+ }
+
+ // Negative control: different referents must stay distinct on BOTH
+ // sides ("tonight" is not "yesterday").
+ if learn.QueryFamily(learn.Normalize("who wins tonight", cfg)) ==
+ learn.QueryFamily(learn.Normalize("who wins yesterday", cfg)) {
+ t.Error("read side folded tonight into yesterday")
+ }
+ if store.NormalizeQuery("who wins tonight") == store.NormalizeQuery("who wins yesterday") {
+ t.Error("write side folded tonight into yesterday")
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/learnings_candidates.go b/library/productivity/human-goat/internal/cli/learnings_candidates.go
new file mode 100644
index 0000000000..83ada4f0b1
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/learnings_candidates.go
@@ -0,0 +1,588 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// learnings_candidates.go is the judgment surface for auto-captured
+// improvement candidates: `learnings candidates` (read-only listing),
+// `learnings confirm ` (prints the full payload, then materializes
+// it per class through the playbook machinery), `learnings reject `
+// (tombstones the derivation signature), and `learnings purge`
+// (operator cleanup, hidden from MCP). Candidates are quarantined in
+// their own table until confirmed; nothing here lifts one past the
+// skip floor without an explicit confirm.
+//
+// It also owns promoteCandidateOnTeach, the shared-key-space hook
+// teach.go fires after a successful teach so an agent teach and an
+// auto-captured candidate for the same query family converge on one
+// artifact instead of two.
+
+package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// registerLearningsCandidateCommands attaches the candidate lifecycle
+// commands to the existing `learnings` group (teach.go's
+// newLearningsCmd calls this).
+func registerLearningsCandidateCommands(parent *cobra.Command, flags *rootFlags) {
+ parent.AddCommand(newLearningsCandidatesCmd(flags))
+ parent.AddCommand(newLearningsConfirmCmd(flags))
+ parent.AddCommand(newLearningsRejectCmd(flags))
+ parent.AddCommand(newLearningsPurgeCmd(flags))
+}
+
+// flagAliasPayload is the payload contract for flag_alias candidates:
+// a failed flag paired with the observed same-session correction.
+type flagAliasPayload struct {
+ CommandPath string `json:"command_path,omitempty"`
+ FailedFlag string `json:"failed_flag"`
+ CorrectedFlag string `json:"corrected_flag"`
+}
+
+// playbookCandidatePayload is the payload contract for
+// playbook_candidate candidates: the synthesized choreography and/or
+// notes for a query family.
+type playbookCandidatePayload struct {
+ QueryFamily string `json:"query_family,omitempty"`
+ PlaybookJSON string `json:"playbook_json,omitempty"`
+ NotesText string `json:"notes_text,omitempty"`
+}
+
+// candidateRow locks the CLI JSON contract for candidate output, the
+// same way learningRow does for `learnings list`.
+type candidateRow struct {
+ ID int64 `json:"id"`
+ Class string `json:"class"`
+ Status string `json:"status"`
+ Sightings int `json:"sightings"`
+ QueryFamily string `json:"query_family,omitempty"`
+ CommandPath string `json:"command_path,omitempty"`
+ Payload json.RawMessage `json:"payload"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSeenAt time.Time `json:"last_seen_at"`
+}
+
+func toCandidateRow(r store.CandidateRow) candidateRow {
+ payload := json.RawMessage(r.Payload)
+ if !json.Valid(payload) {
+ quoted, _ := json.Marshal(r.Payload)
+ payload = quoted
+ }
+ return candidateRow{
+ ID: r.ID,
+ Class: r.Class,
+ Status: r.Status,
+ Sightings: r.Sightings,
+ QueryFamily: r.QueryFamily,
+ CommandPath: r.CommandPath,
+ Payload: payload,
+ CreatedAt: r.CreatedAt,
+ LastSeenAt: r.LastSeenAt,
+ }
+}
+
+// prettyCandidatePayload renders the payload as indented JSON for the
+// confirm preview; a non-JSON payload prints verbatim.
+func prettyCandidatePayload(payload string) string {
+ var buf bytes.Buffer
+ if err := json.Indent(&buf, []byte(payload), "", " "); err != nil {
+ return payload
+ }
+ return buf.String()
+}
+
+func parseCandidateID(args []string) (int64, error) {
+ if len(args) != 1 {
+ return 0, fmt.Errorf("exactly one candidate id is required")
+ }
+ id, err := strconv.ParseInt(strings.TrimSpace(args[0]), 10, 64)
+ if err != nil || id <= 0 {
+ return 0, fmt.Errorf("invalid candidate id %q", args[0])
+ }
+ return id, nil
+}
+
+// newLearningsCandidatesCmd lists candidate rows. Read-only: listing
+// never mutates candidate state.
+func newLearningsCandidatesCmd(flags *rootFlags) *cobra.Command {
+ var classFilter string
+ var statusFilter string
+ var limit int
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "candidates",
+ Short: "List auto-captured improvement candidates awaiting judgment",
+ Long: `Lists rows from the learn_candidates table: corrections and playbook
+shapes the CLI derived from observed usage. Candidates are quarantined
+until an explicit 'learnings confirm ' materializes them; they never
+alter command behavior or recall's verified results on their own.`,
+ Example: ` human-goat-pp-cli learnings candidates
+ human-goat-pp-cli learnings candidates --class flag_alias
+ human-goat-pp-cli learnings candidates --status rejected`,
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ s, err := store.OpenWithContext(cmd.Context(), learnDBPath(dbPath))
+ if err != nil {
+ return fmt.Errorf("learnings candidates: %w", err)
+ }
+ defer s.Close()
+
+ stored, err := s.ListCandidates(store.ListCandidatesFilter{
+ Status: statusFilter,
+ Class: classFilter,
+ Limit: limit,
+ })
+ if err != nil {
+ return fmt.Errorf("learnings candidates: %w", err)
+ }
+ rows := make([]candidateRow, 0, len(stored))
+ for _, r := range stored {
+ rows = append(rows, toCandidateRow(r))
+ }
+
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
+ }
+ if len(rows) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "(no candidates recorded)")
+ return nil
+ }
+ for _, r := range rows {
+ fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\t%s\tsightings=%d\tfamily=%s\n",
+ r.ID, r.Class, r.Status, r.Sightings, r.QueryFamily)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&classFilter, "class", "", "Filter by class (flag_alias | playbook_candidate)")
+ cmd.Flags().StringVar(&statusFilter, "status", store.CandidateStatusOpen, "Filter by status (open | confirmed | rejected | expired; empty for all)")
+ cmd.Flags().IntVar(&limit, "limit", 0, "Maximum rows to return (default: capped)")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
+
+// newLearningsConfirmCmd blesses one open candidate: it prints the
+// full payload first (the agent must see what it blesses), then
+// materializes it per class through the playbook machinery, then marks
+// the row confirmed.
+func newLearningsConfirmCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "confirm ",
+ Short: "Confirm an open candidate: print its full payload, then materialize it",
+ Long: `Promotes one open candidate into the verified knowledge tables.
+
+The full candidate payload is printed before anything is written, so
+the caller always sees exactly what is being blessed. Materialization
+is per class:
+
+ playbook_candidate upserts a playbook row for the candidate's query
+ family (confidence 2, the same floor as a teach)
+ flag_alias appends a timestamped [amend ...] correction note
+ to the query family's playbook notes
+
+Usage errors (unknown id, non-open status) exit 2.`,
+ Example: ` human-goat-pp-cli learnings confirm 3`,
+ // Usage errors here exit 2 by design; declare it so
+ // `cli-printing-press verify` scores the Execute cell honestly.
+ // Confirm materializes into the CLI's own local store only, so it
+ // carries the non-destructive local-write hints.
+ Annotations: map[string]string{"pp:typed-exit-codes": "0,2", "mcp:local-write": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ id, err := parseCandidateID(args)
+ if err != nil {
+ return usageErr(fmt.Errorf("learnings confirm: %w", err))
+ }
+ s, err := store.OpenWithContext(cmd.Context(), learnDBPath(dbPath))
+ if err != nil {
+ return fmt.Errorf("learnings confirm: %w", err)
+ }
+ defer s.Close()
+
+ row, ok, err := s.GetCandidate(id)
+ if err != nil {
+ return fmt.Errorf("learnings confirm: %w", err)
+ }
+ if !ok {
+ return usageErr(fmt.Errorf("learnings confirm: no candidate with id %d", id))
+ }
+ if row.Status != store.CandidateStatusOpen {
+ return usageErr(fmt.Errorf("learnings confirm: candidate %d is %s; only open candidates can be confirmed", id, row.Status))
+ }
+
+ // Print the full payload BEFORE any write lands: the agent
+ // must see what it blesses even if materialization fails.
+ fmt.Fprintf(cmd.OutOrStdout(), "candidate %d (%s, %d sighting(s)) payload:\n%s\n",
+ row.ID, row.Class, row.Sightings, prettyCandidatePayload(row.Payload))
+
+ materialized, err := materializeCandidate(s, row)
+ if err != nil {
+ return fmt.Errorf("learnings confirm: %w", err)
+ }
+ confirmed, err := s.ConfirmCandidate(id)
+ if err != nil {
+ return fmt.Errorf("learnings confirm: %w", err)
+ }
+ // Measurement: telemetry-class, errors to teach.log only.
+ if evErr := s.InsertLearnEvent(store.LearnEventCandidateConfirmed,
+ learn.FamilyHash(row.QueryFamily), 0, false, store.LearnEventSurface()); evErr != nil {
+ writeTeachErrLog(fmt.Sprintf("learnings confirm: event insert: %v", evErr))
+ }
+ _ = appendLearningsAudit(map[string]any{
+ "action": "candidate-confirm",
+ "candidate_id": id,
+ "class": row.Class,
+ "query_family": row.QueryFamily,
+ })
+
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "confirmed": true,
+ "id": confirmed.ID,
+ "class": confirmed.Class,
+ "status": confirmed.Status,
+ "materialized": materialized,
+ }, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "confirmed candidate %d (%s)\n", confirmed.ID, confirmed.Class)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
+
+// confirmingSessionRecallFamily returns the query family of the most
+// recent recall entry journaled under the current session key, or ""
+// when this session has not recalled. It is the KTD8 anchor fallback
+// for a flag-correction candidate derived outside a recall window: the
+// confirming agent recalls the query it cares about, then confirms, and
+// the correction note lands under that family. Best-effort: any journal
+// read error yields "" and the caller reports the missing anchor.
+func confirmingSessionRecallFamily() string {
+ sessionKey := learn.JournalSessionKey()
+ if sessionKey == "" {
+ return ""
+ }
+ entries, _, err := learn.ReadJournalFrom(learn.JournalOffset{})
+ if err != nil {
+ return ""
+ }
+ family := ""
+ for _, e := range entries {
+ if e.SessionKey == sessionKey && len(e.Cmd) > 0 && e.Cmd[0] == "recall" && e.QueryFamily != "" {
+ family = e.QueryFamily
+ }
+ }
+ return family
+}
+
+// materializeCandidate writes a candidate's payload into the verified
+// playbook tables per class and describes what it wrote. Called before
+// the status flips to confirmed, so a failure leaves the candidate
+// open and retryable.
+func materializeCandidate(s *store.Store, row store.CandidateRow) (map[string]any, error) {
+ switch row.Class {
+ case store.CandidateClassPlaybookCandidate:
+ var p playbookCandidatePayload
+ if err := json.Unmarshal([]byte(row.Payload), &p); err != nil {
+ return nil, fmt.Errorf("candidate %d payload is not valid JSON: %w", row.ID, err)
+ }
+ family := strings.TrimSpace(row.QueryFamily)
+ if family == "" {
+ family = strings.TrimSpace(p.QueryFamily)
+ }
+ if family == "" {
+ return nil, fmt.Errorf("candidate %d has no query_family anchor to materialize under", row.ID)
+ }
+ if strings.TrimSpace(p.PlaybookJSON) == "" && strings.TrimSpace(p.NotesText) == "" {
+ return nil, fmt.Errorf("candidate %d payload carries no playbook content", row.ID)
+ }
+ // UpsertPlaybook inserts at confidence 2 — the same floor a
+ // direct teach lands at.
+ pbID, inserted, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: family,
+ PlaybookJSON: p.PlaybookJSON,
+ NotesText: p.NotesText,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("materialize playbook: %w", err)
+ }
+ return map[string]any{
+ "artifact": "playbook",
+ "playbook_id": pbID,
+ "newly_inserted": inserted,
+ "query_family": family,
+ }, nil
+
+ case store.CandidateClassFlagAlias:
+ var p flagAliasPayload
+ if err := json.Unmarshal([]byte(row.Payload), &p); err != nil {
+ return nil, fmt.Errorf("candidate %d payload is not valid JSON: %w", row.ID, err)
+ }
+ family := strings.TrimSpace(row.QueryFamily)
+ if family == "" {
+ // The failure was observed outside a recall window (the
+ // derive-time anchor is empty), so land the correction under
+ // the family the confirming session's own recall resolved.
+ // This is the KTD8 fallback: an agent that recalls, sees the
+ // candidate, then confirms it anchors the note to the query
+ // it is actually working on.
+ family = confirmingSessionRecallFamily()
+ }
+ if family == "" {
+ return nil, fmt.Errorf("candidate %d has no query_family anchor for its correction note; recall the query family first, then confirm", row.ID)
+ }
+ marker := flagAliasAmendMarker(row, p)
+ pbID, inserted, err := s.AppendPlaybookNotes(family, marker)
+ if err != nil {
+ return nil, fmt.Errorf("materialize correction note: %w", err)
+ }
+ return map[string]any{
+ "artifact": "playbook_note",
+ "playbook_id": pbID,
+ "newly_inserted": inserted,
+ "query_family": family,
+ }, nil
+ }
+ return nil, fmt.Errorf("candidate %d has unknown class %q", row.ID, row.Class)
+}
+
+// flagAliasAmendMarker mirrors `playbook amend`'s timestamped note
+// shape so confirmed corrections read like any other amendment.
+func flagAliasAmendMarker(row store.CandidateRow, p flagAliasPayload) string {
+ ts := time.Now().UTC().Format("2006-01-02T15:04Z")
+ commandPath := strings.TrimSpace(p.CommandPath)
+ if commandPath == "" {
+ commandPath = strings.TrimSpace(row.CommandPath)
+ }
+ scope := ""
+ if commandPath != "" {
+ scope = fmt.Sprintf(" on `%s`", commandPath)
+ }
+ if strings.TrimSpace(p.FailedFlag) != "" && strings.TrimSpace(p.CorrectedFlag) != "" {
+ return fmt.Sprintf("\n\n[amend %s]: confirmed flag correction%s: use %s instead of %s",
+ ts, scope, p.CorrectedFlag, p.FailedFlag)
+ }
+ // Payload without the expected fields: preserve it verbatim so
+ // the confirmation still records what was blessed.
+ return fmt.Sprintf("\n\n[amend %s]: confirmed flag correction%s: %s", ts, scope, row.Payload)
+}
+
+// newLearningsRejectCmd tombstones a candidate by its derivation
+// signature. Rejecting a confirmed playbook_candidate rolls back the
+// materialized playbook row; a confirmed flag correction has no reject
+// path in v1 (the repair path is `playbook amend`).
+func newLearningsRejectCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "reject ",
+ Short: "Reject a candidate and tombstone its derivation signature",
+ Long: `Marks a candidate rejected. The rejected row stays as a tombstone on
+its derivation signature: the identical observation never re-derives a
+candidate, and recall never resurfaces it.
+
+Rejecting a CONFIRMED playbook_candidate also deletes the playbook row
+it materialized (a clean rollback). A confirmed flag_alias cannot be
+rejected: its correction note already lives in the family playbook, so
+repair the guidance with 'playbook amend' instead.
+
+Usage errors (unknown id, no-reject-path candidates) exit 2.`,
+ Example: ` human-goat-pp-cli learnings reject 3`,
+ // Usage errors here exit 2 by design; declare it so
+ // `cli-printing-press verify` scores the Execute cell honestly.
+ Annotations: map[string]string{"pp:typed-exit-codes": "0,2"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ id, err := parseCandidateID(args)
+ if err != nil {
+ return usageErr(fmt.Errorf("learnings reject: %w", err))
+ }
+ s, err := store.OpenWithContext(cmd.Context(), learnDBPath(dbPath))
+ if err != nil {
+ return fmt.Errorf("learnings reject: %w", err)
+ }
+ defer s.Close()
+
+ row, ok, err := s.GetCandidate(id)
+ if err != nil {
+ return fmt.Errorf("learnings reject: %w", err)
+ }
+ if !ok {
+ return usageErr(fmt.Errorf("learnings reject: no candidate with id %d", id))
+ }
+ wasConfirmed := row.Status == store.CandidateStatusConfirmed
+
+ rejected, err := s.RejectCandidate(id)
+ if errors.Is(err, store.ErrConfirmedFlagAliasReject) {
+ return usageErr(fmt.Errorf("learnings reject: candidate %d is a confirmed flag correction with no reject path; repair the guidance with \"human-goat-pp-cli playbook amend\" instead", id))
+ }
+ if err != nil {
+ return usageErr(fmt.Errorf("learnings reject: %w", err))
+ }
+ // Measurement: telemetry-class, errors to teach.log only.
+ if evErr := s.InsertLearnEvent(store.LearnEventCandidateRejected,
+ learn.FamilyHash(row.QueryFamily), 0, false, store.LearnEventSurface()); evErr != nil {
+ writeTeachErrLog(fmt.Sprintf("learnings reject: event insert: %v", evErr))
+ }
+ _ = appendLearningsAudit(map[string]any{
+ "action": "candidate-reject",
+ "candidate_id": id,
+ "class": row.Class,
+ "query_family": row.QueryFamily,
+ })
+
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "rejected": true,
+ "id": rejected.ID,
+ "class": rejected.Class,
+ "status": rejected.Status,
+ "tombstoned_signature": rejected.DerivationSignature,
+ "rolled_back_playbook": wasConfirmed && rejected.Class == store.CandidateClassPlaybookCandidate,
+ }, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "rejected candidate %d (%s); signature tombstoned\n", rejected.ID, rejected.Class)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
+
+// newLearningsPurgeCmd is operator cleanup for settled candidate rows.
+// Hidden from MCP: purging tombstones re-allows derivation of rejected
+// signatures, a call that needs a human in the loop.
+func newLearningsPurgeCmd(flags *rootFlags) *cobra.Command {
+ var includeTombstones bool
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "purge",
+ Short: "Delete settled candidate rows (expired and confirmed; --tombstones adds rejected)",
+ Long: `Deletes expired and confirmed candidate rows. Materialized artifacts
+(playbook rows, correction notes) are untouched. Rejected rows are kept
+by default because they tombstone their derivation signatures; pass
+--tombstones to remove them too, which re-allows derivation of those
+signatures. Open rows are never purged.
+
+Open rows past the unseen TTL are swept to expired first, so a single
+purge also clears stale candidates nobody judged.`,
+ Example: ` human-goat-pp-cli learnings purge
+ human-goat-pp-cli learnings purge --tombstones`,
+ Annotations: map[string]string{"mcp:hidden": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ s, err := store.OpenWithContext(cmd.Context(), learnDBPath(dbPath))
+ if err != nil {
+ return fmt.Errorf("learnings purge: %w", err)
+ }
+ defer s.Close()
+
+ expired, err := s.ExpireCandidates(0)
+ if err != nil {
+ return fmt.Errorf("learnings purge: %w", err)
+ }
+ purged, err := s.PurgeCandidates(includeTombstones)
+ if err != nil {
+ return fmt.Errorf("learnings purge: %w", err)
+ }
+ _ = appendLearningsAudit(map[string]any{
+ "action": "candidate-purge",
+ "purged": purged,
+ "expired": expired,
+ "tombstones": includeTombstones,
+ })
+
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "purged": purged,
+ "expired": expired,
+ "tombstones": includeTombstones,
+ }, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "purged %d candidate row(s) (%d swept to expired first)\n", purged, expired)
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&includeTombstones, "tombstones", false, "Also delete rejected rows (re-allows derivation of their signatures)")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
+
+// promoteCandidateOnTeach implements the shared key space between
+// agent teach and auto-capture: a successful teach whose query family
+// matches an open playbook candidate promotes that candidate
+// (materialize if needed + confirm) instead of leaving the agent's
+// artifact and the auto-capture as duplicates. When the teach already
+// produced a playbook row for the family, the candidate is confirmed
+// without re-materializing, so exactly one artifact remains either way.
+//
+// Best-effort by contract: teach.go logs any returned error to
+// teach.log and never fails the teach itself.
+func promoteCandidateOnTeach(s *store.Store, family string) error {
+ if s == nil || strings.TrimSpace(family) == "" {
+ return nil
+ }
+ open, err := s.ListCandidates(store.ListCandidatesFilter{
+ Status: store.CandidateStatusOpen,
+ Class: store.CandidateClassPlaybookCandidate,
+ QueryFamily: family,
+ })
+ if err != nil {
+ return err
+ }
+ var firstErr error
+ record := func(err error) {
+ if firstErr == nil {
+ firstErr = err
+ }
+ }
+ for _, row := range open {
+ _, exists, err := s.GetPlaybookByFamily(family)
+ if err != nil {
+ record(err)
+ continue
+ }
+ if !exists {
+ if _, err := materializeCandidate(s, row); err != nil {
+ record(err)
+ continue
+ }
+ }
+ if _, err := s.ConfirmCandidate(row.ID); err != nil {
+ record(err)
+ continue
+ }
+ _ = appendLearningsAudit(map[string]any{
+ "action": "candidate-promote-on-teach",
+ "candidate_id": row.ID,
+ "query_family": family,
+ })
+ }
+ return firstErr
+}
diff --git a/library/productivity/human-goat/internal/cli/learnings_candidates_test.go b/library/productivity/human-goat/internal/cli/learnings_candidates_test.go
new file mode 100644
index 0000000000..a2597f4623
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/learnings_candidates_test.go
@@ -0,0 +1,454 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// seedCandidate derives one candidate row directly through the store
+// and returns its id. The store handle is closed again so the CLI
+// under test owns the DB when it runs.
+func seedCandidate(t *testing.T, dbPath, class, payload, signature, family, commandPath string) int64 {
+ t.Helper()
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ defer s.Close()
+ row, inserted, err := s.DeriveCandidate(class, payload, signature, family, commandPath)
+ if err != nil {
+ t.Fatalf("derive candidate: %v", err)
+ }
+ if !inserted {
+ t.Fatalf("seed candidate %q should insert", signature)
+ }
+ return row.ID
+}
+
+func getCandidateStatus(t *testing.T, dbPath string, id int64) string {
+ t.Helper()
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ defer s.Close()
+ row, ok, err := s.GetCandidate(id)
+ if err != nil || !ok {
+ t.Fatalf("get candidate %d: err=%v ok=%v", id, err, ok)
+ }
+ return row.Status
+}
+
+func TestLearningsCandidates_ListsOpenRows(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ seedCandidate(t, dbPath, store.CandidateClassFlagAlias,
+ `{"failed_flag":"--date","corrected_flag":"--dates","command_path":"items list"}`,
+ "sig-list-fa", "fam-list", "items list")
+ seedCandidate(t, dbPath, store.CandidateClassPlaybookCandidate,
+ `{"notes_text":"list before fetch"}`,
+ "sig-list-pb", "fam-list", "")
+
+ stdout, stderr, err := runRootArgs(t, "learnings", "candidates", "--db", dbPath)
+ if err != nil {
+ t.Fatalf("learnings candidates: %v (stderr=%q)", err, stderr)
+ }
+ var rows []candidateRow
+ if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
+ t.Fatalf("candidates JSON: %v (stdout=%q)", err, stdout)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("want 2 open candidates, got %d (%q)", len(rows), stdout)
+ }
+ for _, r := range rows {
+ if r.Status != store.CandidateStatusOpen {
+ t.Errorf("default listing must be open rows, got %q", r.Status)
+ }
+ }
+
+ // --class narrows to one class.
+ stdout, _, err = runRootArgs(t, "learnings", "candidates", "--db", dbPath, "--class", store.CandidateClassFlagAlias)
+ if err != nil {
+ t.Fatalf("learnings candidates --class: %v", err)
+ }
+ rows = nil
+ if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
+ t.Fatalf("candidates --class JSON: %v (stdout=%q)", err, stdout)
+ }
+ if len(rows) != 1 || rows[0].Class != store.CandidateClassFlagAlias {
+ t.Fatalf("want exactly the flag_alias row, got %q", stdout)
+ }
+}
+
+func TestLearningsConfirm_PlaybookCandidateMaterializesAtConfidence2(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ id := seedCandidate(t, dbPath, store.CandidateClassPlaybookCandidate,
+ `{"playbook_json":"{\"steps\":[{\"cmd\":\"records list --limit 5\"}]}","notes_text":"start narrow"}`,
+ "sig-conf-pb", "fam-confirm", "")
+
+ stdout, stderr, err := runRootArgs(t, "learnings", "confirm", itoa64(id), "--db", dbPath)
+ if err != nil {
+ t.Fatalf("learnings confirm: %v (stderr=%q)", err, stderr)
+ }
+ // The full payload prints before the confirmation record: the
+ // agent must see what it blesses.
+ payloadIdx := strings.Index(stdout, "start narrow")
+ confirmIdx := strings.Index(stdout, `"confirmed"`)
+ if payloadIdx < 0 {
+ t.Fatalf("confirm must print the full payload; stdout=%q", stdout)
+ }
+ if confirmIdx < 0 {
+ t.Fatalf("confirm must emit a confirmation record; stdout=%q", stdout)
+ }
+ if payloadIdx > confirmIdx {
+ t.Errorf("payload must print before the confirmation record; stdout=%q", stdout)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen store: %v", err)
+ }
+ defer s.Close()
+ pb, ok, err := s.GetPlaybookByFamily("fam-confirm")
+ if err != nil || !ok {
+ t.Fatalf("materialized playbook missing: err=%v ok=%v", err, ok)
+ }
+ if pb.Confidence != 2 {
+ t.Errorf("materialized playbook must land at confidence 2, got %d", pb.Confidence)
+ }
+ if !strings.Contains(pb.PlaybookJSON, "records list --limit 5") {
+ t.Errorf("playbook content mismatch: %q", pb.PlaybookJSON)
+ }
+ row, ok, err := s.GetCandidate(id)
+ if err != nil || !ok {
+ t.Fatalf("get candidate: err=%v ok=%v", err, ok)
+ }
+ if row.Status != store.CandidateStatusConfirmed {
+ t.Errorf("candidate must be confirmed, got %q", row.Status)
+ }
+}
+
+func TestLearningsReject_ConfirmedPlaybookCandidateDeletesMaterializedRow(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ id := seedCandidate(t, dbPath, store.CandidateClassPlaybookCandidate,
+ `{"playbook_json":"{\"steps\":[{\"cmd\":\"records list\"}]}"}`,
+ "sig-rej-pb", "fam-rollback", "")
+
+ if _, _, err := runRootArgs(t, "learnings", "confirm", itoa64(id), "--db", dbPath); err != nil {
+ t.Fatalf("confirm: %v", err)
+ }
+ if _, _, err := runRootArgs(t, "learnings", "reject", itoa64(id), "--db", dbPath); err != nil {
+ t.Fatalf("reject confirmed playbook candidate: %v", err)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen store: %v", err)
+ }
+ defer s.Close()
+ if _, ok, err := s.GetPlaybookByFamily("fam-rollback"); err != nil || ok {
+ t.Errorf("rollback must delete the materialized playbook row: ok=%v err=%v", ok, err)
+ }
+ row, ok, err := s.GetCandidate(id)
+ if err != nil || !ok {
+ t.Fatalf("get candidate: err=%v ok=%v", err, ok)
+ }
+ if row.Status != store.CandidateStatusRejected {
+ t.Errorf("candidate must be rejected, got %q", row.Status)
+ }
+}
+
+func TestLearningsConfirm_FlagAliasAppendsAmendNote(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ id := seedCandidate(t, dbPath, store.CandidateClassFlagAlias,
+ `{"failed_flag":"--day","corrected_flag":"--date","command_path":"records list"}`,
+ "sig-conf-fa", "fam-flagfix", "records list")
+
+ stdout, stderr, err := runRootArgs(t, "learnings", "confirm", itoa64(id), "--db", dbPath)
+ if err != nil {
+ t.Fatalf("learnings confirm flag_alias: %v (stderr=%q)", err, stderr)
+ }
+ if !strings.Contains(stdout, "--date") {
+ t.Errorf("confirm must print the payload; stdout=%q", stdout)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen store: %v", err)
+ }
+ defer s.Close()
+ pb, ok, err := s.GetPlaybookByFamily("fam-flagfix")
+ if err != nil || !ok {
+ t.Fatalf("family playbook missing after flag_alias confirm: err=%v ok=%v", err, ok)
+ }
+ if !strings.Contains(pb.NotesText, "[amend ") {
+ t.Errorf("correction must append a timestamped [amend ...] marker, got %q", pb.NotesText)
+ }
+ if !strings.Contains(pb.NotesText, "--date") || !strings.Contains(pb.NotesText, "--day") {
+ t.Errorf("correction note must carry both flags, got %q", pb.NotesText)
+ }
+}
+
+func TestLearningsReject_ConfirmedFlagAliasNamesAmendRepairPath(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ id := seedCandidate(t, dbPath, store.CandidateClassFlagAlias,
+ `{"failed_flag":"--kind","corrected_flag":"--type","command_path":"items list"}`,
+ "sig-rej-fa", "fam-norollback", "items list")
+
+ if _, _, err := runRootArgs(t, "learnings", "confirm", itoa64(id), "--db", dbPath); err != nil {
+ t.Fatalf("confirm: %v", err)
+ }
+
+ _, _, err := runRootArgs(t, "learnings", "reject", itoa64(id), "--db", dbPath)
+ if err == nil {
+ t.Fatal("rejecting a confirmed flag_alias must exit non-zero")
+ }
+ if !strings.Contains(err.Error(), "playbook amend") {
+ t.Errorf("the usage error must name the playbook amend repair path, got %q", err.Error())
+ }
+ if got := getCandidateStatus(t, dbPath, id); got != store.CandidateStatusConfirmed {
+ t.Errorf("refused reject must leave the candidate confirmed, got %q", got)
+ }
+}
+
+func TestLearningsReject_OpenCandidateTombstonesDerivation(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ id := seedCandidate(t, dbPath, store.CandidateClassFlagAlias,
+ `{"failed_flag":"--from","corrected_flag":"--since"}`,
+ "sig-rej-open", "", "records list")
+
+ if _, _, err := runRootArgs(t, "learnings", "reject", itoa64(id), "--db", dbPath); err != nil {
+ t.Fatalf("reject open candidate: %v", err)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen store: %v", err)
+ }
+ defer s.Close()
+ row, inserted, err := s.DeriveCandidate(store.CandidateClassFlagAlias,
+ `{"failed_flag":"--from","corrected_flag":"--since"}`,
+ "sig-rej-open", "", "records list")
+ if err != nil {
+ t.Fatalf("re-derive on tombstone: %v", err)
+ }
+ if inserted || row.Status != store.CandidateStatusRejected || row.Sightings != 1 {
+ t.Errorf("tombstoned signature must no-op: inserted=%v status=%q sightings=%d",
+ inserted, row.Status, row.Sightings)
+ }
+}
+
+func TestLearningsPurge_KeepsTombstonesUnlessAsked(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ rejectID := seedCandidate(t, dbPath, store.CandidateClassFlagAlias,
+ `{"failed_flag":"--x","corrected_flag":"--y"}`,
+ "sig-purge-cli", "", "items list")
+ if _, _, err := runRootArgs(t, "learnings", "reject", itoa64(rejectID), "--db", dbPath); err != nil {
+ t.Fatalf("reject: %v", err)
+ }
+
+ stdout, _, err := runRootArgs(t, "learnings", "purge", "--db", dbPath)
+ if err != nil {
+ t.Fatalf("purge: %v", err)
+ }
+ var res struct {
+ Purged int64 `json:"purged"`
+ Tombstones bool `json:"tombstones"`
+ }
+ if err := json.Unmarshal([]byte(stdout), &res); err != nil {
+ t.Fatalf("purge JSON: %v (stdout=%q)", err, stdout)
+ }
+ if res.Purged != 0 {
+ t.Errorf("default purge must keep the tombstone, purged %d", res.Purged)
+ }
+
+ stdout, _, err = runRootArgs(t, "learnings", "purge", "--tombstones", "--db", dbPath)
+ if err != nil {
+ t.Fatalf("purge --tombstones: %v", err)
+ }
+ if err := json.Unmarshal([]byte(stdout), &res); err != nil {
+ t.Fatalf("purge --tombstones JSON: %v (stdout=%q)", err, stdout)
+ }
+ if res.Purged != 1 || !res.Tombstones {
+ t.Errorf("tombstone purge must remove the rejected row, got %q", stdout)
+ }
+}
+
+// TestTeachPromotesOpenCandidate_OneArtifact pins the shared key
+// space: a teach whose query family matches an open playbook candidate
+// promotes it (confirm + materialize) so exactly one artifact remains.
+func TestTeachPromotesOpenCandidate_OneArtifact(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ // Compute the family exactly the way teach does.
+ query := "find all widgets in inventory"
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ cfg := newLearnConfig()
+ normalized := learn.Normalize(query, cfg)
+ normalized = learn.PromoteEntities(normalized, learn.NewCanonicalResolver(context.Background(), s.DB()))
+ family := learn.QueryFamily(normalized)
+ if family == "" {
+ s.Close()
+ t.Fatal("test query must normalize to a non-empty family")
+ }
+ row, inserted, err := s.DeriveCandidate(store.CandidateClassPlaybookCandidate,
+ `{"notes_text":"run the list command with a small limit first"}`,
+ "sig-teach-promote", family, "")
+ if err != nil || !inserted {
+ t.Fatalf("seed candidate: err=%v inserted=%v", err, inserted)
+ }
+ s.Close()
+
+ _, stderr, err := runRootArgs(t,
+ "teach",
+ "--query", query,
+ "--resource", "widget-42",
+ "--resource-type", "items",
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach: %v (stderr=%q)", err, stderr)
+ }
+
+ s, err = store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen store: %v", err)
+ }
+ defer s.Close()
+ got, ok, err := s.GetCandidate(row.ID)
+ if err != nil || !ok {
+ t.Fatalf("get candidate: err=%v ok=%v", err, ok)
+ }
+ if got.Status != store.CandidateStatusConfirmed {
+ t.Errorf("teach must promote the matching open candidate, got status %q", got.Status)
+ }
+ // One artifact, not two: exactly one playbook row for the family,
+ // carrying the candidate's synthesized guidance.
+ pb, ok, err := s.GetPlaybookByFamily(family)
+ if err != nil || !ok {
+ t.Fatalf("promoted playbook missing: err=%v ok=%v", err, ok)
+ }
+ if !strings.Contains(pb.NotesText, "small limit") {
+ t.Errorf("promoted playbook must carry the candidate guidance, got %q", pb.NotesText)
+ }
+ pbs, err := s.ListPlaybooks()
+ if err != nil {
+ t.Fatalf("list playbooks: %v", err)
+ }
+ count := 0
+ for _, p := range pbs {
+ if p.QueryFamily == family {
+ count++
+ }
+ }
+ if count != 1 {
+ t.Errorf("want exactly one playbook artifact for the family, got %d", count)
+ }
+}
+
+// itoa64 renders candidate ids as command-line args.
+func itoa64(id int64) string {
+ return strconv.FormatInt(id, 10)
+}
+
+// TestLearnEvents_ConfirmRecordsCandidateConfirmed pins the judgment
+// half of the measurement layer: a successful confirm records a
+// candidate_confirmed event keyed by the candidate's family hash.
+func TestLearnEvents_ConfirmRecordsCandidateConfirmed(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ id := seedCandidate(t, dbPath, store.CandidateClassPlaybookCandidate,
+ `{"notes_text":"list before fetch"}`,
+ "sig-ev-confirm", "fam-ev-confirm", "")
+
+ if _, _, err := runRootArgs(t,
+ "learnings", "confirm", itoa64(id), "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("confirm: %v", err)
+ }
+
+ events := readLearnEvents(t, dbPath)
+ confirmed := filterLearnEvents(events, store.LearnEventCandidateConfirmed)
+ if len(confirmed) != 1 {
+ t.Fatalf("want 1 candidate_confirmed event, got %d (%+v)", len(confirmed), events)
+ }
+ if want := learn.FamilyHash("fam-ev-confirm"); confirmed[0].FamilyHash != want {
+ t.Errorf("event family hash = %q, want %q", confirmed[0].FamilyHash, want)
+ }
+}
+
+// TestLearnEvents_RejectRecordsCandidateRejected mirrors the confirm
+// pin for the reject path.
+func TestLearnEvents_RejectRecordsCandidateRejected(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ id := seedCandidate(t, dbPath, store.CandidateClassFlagAlias,
+ `{"failed_flag":"--date","corrected_flag":"--dates","command_path":"items list"}`,
+ "sig-ev-reject", "fam-ev-reject", "items list")
+
+ if _, _, err := runRootArgs(t,
+ "learnings", "reject", itoa64(id), "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("reject: %v", err)
+ }
+
+ events := readLearnEvents(t, dbPath)
+ rejected := filterLearnEvents(events, store.LearnEventCandidateRejected)
+ if len(rejected) != 1 {
+ t.Fatalf("want 1 candidate_rejected event, got %d (%+v)", len(rejected), events)
+ }
+ if want := learn.FamilyHash("fam-ev-reject"); rejected[0].FamilyHash != want {
+ t.Errorf("event family hash = %q, want %q", rejected[0].FamilyHash, want)
+ }
+}
+
+// TestLearnEvents_FailedConfirmRecordsNoEvent guards the judgment
+// counters against inflation: a confirm that errors (unknown id)
+// records nothing.
+func TestLearnEvents_FailedConfirmRecordsNoEvent(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ // Materialize the DB so the events readback has a store to open.
+ seedCandidate(t, dbPath, store.CandidateClassPlaybookCandidate,
+ `{"notes_text":"anything"}`, "sig-ev-noop", "fam-ev-noop", "")
+
+ if _, _, err := runRootArgs(t,
+ "learnings", "confirm", "99999", "--db", dbPath,
+ ); err == nil {
+ t.Fatal("confirm of unknown id should error")
+ }
+
+ if events := readLearnEvents(t, dbPath); len(events) != 0 {
+ t.Errorf("failed confirm must record no events, got %+v", events)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/learnings_stats.go b/library/productivity/human-goat/internal/cli/learnings_stats.go
new file mode 100644
index 0000000000..f6c556c8db
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/learnings_stats.go
@@ -0,0 +1,137 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// learnings_stats.go is the measurement readout for the learn loop:
+// `learnings stats` aggregates the local learn_events table into the
+// four headline metrics (recall hit rate, teach-to-reuse, playbook
+// resolution success, candidate confirm/reject counts) so the loop's
+// value is inspectable per-CLI without any remote reporting. It also
+// runs the retention prune opportunistically, keeping the events
+// table capped without a background job.
+
+package cli
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// learnStatsEnvelope locks the JSON contract for `learnings stats`.
+// The three rate fields serialize as null when their denominator is
+// zero (an empty or thin events table means "no signal yet", never a
+// fake zero rate); the count fields expose each rate's denominator.
+type learnStatsEnvelope struct {
+ RecallHitRate *float64 `json:"recall_hit_rate"`
+ RecallHits int64 `json:"recall_hits"`
+ RecallMisses int64 `json:"recall_misses"`
+ TeachToReuse *float64 `json:"teach_to_reuse"`
+ TaughtRows int64 `json:"taught_rows"`
+ ReusedRows int64 `json:"reused_rows"`
+ PlaybookResolutionRate *float64 `json:"playbook_resolution_rate"`
+ PlaybookHits int64 `json:"playbook_hits"`
+ PlaybookHitsAmended int64 `json:"playbook_hits_amended"`
+ CandidatesConfirmed int64 `json:"candidates_confirmed"`
+ CandidatesRejected int64 `json:"candidates_rejected"`
+ EventCounts map[string]int64 `json:"event_counts"`
+ EventsPruned int64 `json:"events_pruned"`
+}
+
+// newLearningsStatsCmd builds `learnings stats`, registered on the
+// learnings group by teach.go's newLearningsCmd. Read-only in the
+// domain sense — the only write is the telemetry-class retention
+// prune, which is best-effort, invisible in domain output, and never
+// fails the command (the AGENTS.md telemetry carve-out `recall`'s
+// event insert rides on).
+func newLearningsStatsCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "stats",
+ Short: "Report learn-loop effectiveness metrics from the local events table",
+ Long: `Aggregates the local learn_events table into the loop's headline
+metrics:
+
+ recall_hit_rate recall hits / (hits + misses)
+ teach_to_reuse fraction of taught learnings later seen in a
+ recall hit (joined by learning row id, with a
+ query-family fallback for older rows)
+ playbook_resolution_rate playbook hits not followed by an amend on the
+ same query family
+ candidates_confirmed/rejected judgment counts on auto-captured candidates
+
+Rates are null until their denominator is non-zero. Everything is
+local-only: events never leave this machine. Old events are pruned
+opportunistically on each run (default cap 10000 rows / 90 days).`,
+ Example: ` human-goat-pp-cli learnings stats
+ human-goat-pp-cli learnings stats --json`,
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ s, err := store.OpenWithContext(cmd.Context(), learnDBPath(dbPath))
+ if err != nil {
+ return fmt.Errorf("learnings stats: %w", err)
+ }
+ defer s.Close()
+
+ // Opportunistic retention (R21). Best-effort: a prune
+ // failure logs to teach.log and the stats still report.
+ pruned, pruneErr := s.PruneLearnEvents(0, 0)
+ if pruneErr != nil {
+ writeTeachErrLog(fmt.Sprintf("learnings stats: prune: %v", pruneErr))
+ }
+
+ stats, err := s.LearnEventStats(cmd.Context())
+ if err != nil {
+ return fmt.Errorf("learnings stats: %w", err)
+ }
+ env := learnStatsEnvelope{
+ RecallHitRate: stats.RecallHitRate,
+ RecallHits: stats.RecallHits,
+ RecallMisses: stats.RecallMisses,
+ TeachToReuse: stats.TeachToReuse,
+ TaughtRows: stats.TaughtRows,
+ ReusedRows: stats.ReusedRows,
+ PlaybookResolutionRate: stats.PlaybookResolutionRate,
+ PlaybookHits: stats.PlaybookHits,
+ PlaybookHitsAmended: stats.PlaybookHitsAmended,
+ CandidatesConfirmed: stats.CandidatesConfirmed,
+ CandidatesRejected: stats.CandidatesRejected,
+ EventCounts: stats.EventCounts,
+ EventsPruned: pruned,
+ }
+
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), env, flags)
+ }
+ out := cmd.OutOrStdout()
+ fmt.Fprintf(out, "recall hit rate: %s (%d hits / %d misses)\n",
+ formatStatRate(env.RecallHitRate), env.RecallHits, env.RecallMisses)
+ fmt.Fprintf(out, "teach-to-reuse: %s (%d of %d taught rows reused)\n",
+ formatStatRate(env.TeachToReuse), env.ReusedRows, env.TaughtRows)
+ fmt.Fprintf(out, "playbook resolution rate: %s (%d hits, %d later amended)\n",
+ formatStatRate(env.PlaybookResolutionRate), env.PlaybookHits, env.PlaybookHitsAmended)
+ fmt.Fprintf(out, "candidates: %d confirmed, %d rejected\n",
+ env.CandidatesConfirmed, env.CandidatesRejected)
+ if env.EventsPruned > 0 {
+ fmt.Fprintf(out, "(pruned %d old event(s) this run)\n", env.EventsPruned)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
+
+// formatStatRate renders a nullable rate for the human surface: "n/a"
+// until the metric has a denominator.
+func formatStatRate(rate *float64) string {
+ if rate == nil {
+ return "n/a"
+ }
+ return fmt.Sprintf("%.0f%%", *rate*100)
+}
diff --git a/library/productivity/human-goat/internal/cli/learnings_stats_test.go b/library/productivity/human-goat/internal/cli/learnings_stats_test.go
new file mode 100644
index 0000000000..388896039d
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/learnings_stats_test.go
@@ -0,0 +1,175 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// runLearningsStatsJSON executes `learnings stats --json` against the
+// given DB and decodes the envelope.
+func runLearningsStatsJSON(t *testing.T, dbPath string) learnStatsEnvelope {
+ t.Helper()
+ stdout, stderr, err := runRootArgs(t,
+ "learnings", "stats",
+ "--db", dbPath,
+ "--json",
+ )
+ if err != nil {
+ t.Fatalf("learnings stats: %v (stderr=%q)", err, stderr)
+ }
+ var env learnStatsEnvelope
+ if err := json.Unmarshal([]byte(stdout), &env); err != nil {
+ t.Fatalf("stats JSON: %v (stdout=%q)", err, stdout)
+ }
+ return env
+}
+
+// TestLearningsStats_EmptyTableGuardsZeroDivision pins the empty-store
+// contract: all three rates serialize as null (never 0-by-0 division,
+// never a fake 0%), counts are zero, and the command still exits 0.
+func TestLearningsStats_EmptyTableGuardsZeroDivision(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ // The raw JSON must carry literal nulls for the rates.
+ stdout, _, err := runRootArgs(t, "learnings", "stats", "--db", dbPath, "--json")
+ if err != nil {
+ t.Fatalf("learnings stats: %v", err)
+ }
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(stdout), &raw); err != nil {
+ t.Fatalf("stats JSON: %v (stdout=%q)", err, stdout)
+ }
+ for _, key := range []string{"recall_hit_rate", "teach_to_reuse", "playbook_resolution_rate"} {
+ v, ok := raw[key]
+ if !ok {
+ t.Errorf("stats JSON missing %q key", key)
+ continue
+ }
+ if string(v) != "null" {
+ t.Errorf("%s = %s on an empty table, want null", key, v)
+ }
+ }
+ for _, key := range []string{"candidates_confirmed", "candidates_rejected", "recall_hits", "recall_misses"} {
+ if v, ok := raw[key]; !ok || string(v) != "0" {
+ t.Errorf("%s = %s on an empty table, want 0", key, v)
+ }
+ }
+}
+
+// TestLearningsStats_ReportsFourHeadlineMetrics drives the real
+// commands (recall miss -> teach -> recall hit) plus seeded playbook
+// and candidate events, then asserts each headline metric computes.
+func TestLearningsStats_ReportsFourHeadlineMetrics(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ // Cold miss, teach, warm hit — the canonical loop.
+ if _, _, err := runRootArgs(t,
+ "recall", "find all widgets in inventory", "--db", dbPath, "--agent",
+ ); err != nil {
+ t.Fatalf("cold recall: %v", err)
+ }
+ if _, _, err := runRootArgs(t,
+ "teach",
+ "--query", "find all widgets in inventory",
+ "--resource", "widget-42",
+ "--resource-type", "items",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+ if _, _, err := runRootArgs(t,
+ "recall", "find all widgets in inventory", "--db", dbPath, "--agent",
+ ); err != nil {
+ t.Fatalf("warm recall: %v", err)
+ }
+
+ // Playbook + candidate events via the store API (their command
+ // paths are covered by their own suites).
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ if err := s.InsertLearnEvent(store.LearnEventRecallPlaybookHit, "fam-pb", 0, false, store.LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed playbook hit: %v", err)
+ }
+ if err := s.InsertLearnEvent(store.LearnEventCandidateConfirmed, "fam-c", 0, false, store.LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed confirm: %v", err)
+ }
+ if err := s.InsertLearnEvent(store.LearnEventCandidateRejected, "fam-r", 0, false, store.LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed reject: %v", err)
+ }
+ s.Close()
+
+ env := runLearningsStatsJSON(t, dbPath)
+ if env.RecallHits != 1 || env.RecallMisses != 1 {
+ t.Errorf("hits/misses = %d/%d, want 1/1", env.RecallHits, env.RecallMisses)
+ }
+ if env.RecallHitRate == nil || *env.RecallHitRate != 0.5 {
+ t.Errorf("recall_hit_rate = %v, want 0.5", env.RecallHitRate)
+ }
+ if env.TaughtRows != 1 || env.ReusedRows != 1 {
+ t.Errorf("taught/reused = %d/%d, want 1/1", env.TaughtRows, env.ReusedRows)
+ }
+ if env.TeachToReuse == nil || *env.TeachToReuse != 1.0 {
+ t.Errorf("teach_to_reuse = %v, want 1.0", env.TeachToReuse)
+ }
+ if env.PlaybookHits != 1 {
+ t.Errorf("playbook_hits = %d, want 1", env.PlaybookHits)
+ }
+ if env.PlaybookResolutionRate == nil || *env.PlaybookResolutionRate != 1.0 {
+ t.Errorf("playbook_resolution_rate = %v, want 1.0 (no amend recorded)", env.PlaybookResolutionRate)
+ }
+ if env.CandidatesConfirmed != 1 || env.CandidatesRejected != 1 {
+ t.Errorf("candidates = %d confirmed / %d rejected, want 1/1",
+ env.CandidatesConfirmed, env.CandidatesRejected)
+ }
+ if env.EventCounts[store.LearnEventTeach] != 1 {
+ t.Errorf("event_counts[teach] = %d, want 1", env.EventCounts[store.LearnEventTeach])
+ }
+}
+
+// TestLearningsStats_PrunesOpportunistically pins the retention hook:
+// stats runs the prune (default cap; here verified via the age half by
+// backdating rows beyond 90 days) and still reports.
+func TestLearningsStats_PrunesOpportunistically(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ for i := 0; i < 3; i++ {
+ if err := s.InsertLearnEvent(store.LearnEventRecallMiss, "", 0, false, store.LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed %d: %v", i, err)
+ }
+ }
+ // Backdate one row beyond the 90-day default so the stats-time
+ // prune has something to drop.
+ if _, err := s.DB().Exec(
+ `UPDATE learn_events SET ts = '2000-01-01T00:00:00Z' WHERE id = 1`,
+ ); err != nil {
+ t.Fatalf("backdate: %v", err)
+ }
+ s.Close()
+
+ env := runLearningsStatsJSON(t, dbPath)
+ if env.EventsPruned != 1 {
+ t.Errorf("events_pruned = %d, want 1", env.EventsPruned)
+ }
+ if env.RecallMisses != 2 {
+ t.Errorf("recall_misses after prune = %d, want 2", env.RecallMisses)
+ }
+ if env.RecallHitRate == nil || *env.RecallHitRate != 0 {
+ t.Errorf("recall_hit_rate = %v, want 0 (misses only)", env.RecallHitRate)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/magicstore.go b/library/productivity/human-goat/internal/cli/magicstore.go
new file mode 100644
index 0000000000..67a393d04b
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/magicstore.go
@@ -0,0 +1,33 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/magic"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// persistMagicRequest records a Magic request in the local store so `status`
+// can surface it as part of the cross-source inbox (the Magic API has no
+// list-requests endpoint, so the local record is the only inbox source).
+// Best-effort: the request already succeeded remotely, so a store failure must
+// never fail the user-facing command.
+func persistMagicRequest(ctx context.Context, req *magic.Request) {
+ if req == nil || strings.TrimSpace(req.ID) == "" {
+ return
+ }
+ data, err := json.Marshal(req)
+ if err != nil {
+ return
+ }
+ db, err := store.OpenWithContext(ctx, defaultDBPath("human-goat-pp-cli"))
+ if err != nil {
+ return
+ }
+ defer db.Close()
+ _ = db.Upsert("magic", req.ID, data)
+}
diff --git a/library/productivity/human-goat/internal/cli/playbook_init.go b/library/productivity/human-goat/internal/cli/playbook_init.go
new file mode 100644
index 0000000000..75a0f94a76
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/playbook_init.go
@@ -0,0 +1,270 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// playbook_init.go is the per-CLI playbook auto-install path. At
+// first DB open after schema migration, this reads the embed.FS from
+// internal/cli/playbooks and seeds the learning_playbooks table.
+//
+// A sentinel row (query_family = "__seed_meta__") tracks the seed
+// version. Subsequent invocations short-circuit when the sentinel
+// matches. Binary upgrades that bump the SeedVersion constant
+// trigger re-seed. User-authored playbooks via teach-playbook have
+// different query_family keys and are untouched by re-seed.
+// `playbook amend` does share family keys with seeded rows, so the
+// seed loop checks each existing row and suppresses its embedded
+// notes when the agent has already written notes_text — agent
+// gotchas survive binary upgrades.
+//
+// Failures degrade gracefully: stderr warning, CLI continues without
+// seeded playbooks (recall returns the empty playbook envelope, same
+// as an opt-out CLI).
+
+package cli
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "os"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cli/playbooks"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// playbookSeedSentinelFamily is the synthetic query_family used to
+// track the most recent seed version. notes_text stores SeedVersion;
+// absent/mismatched value triggers re-seed.
+const playbookSeedSentinelFamily = "__seed_meta__"
+
+// amendMarkerRe matches the durable signal `playbook amend` writes
+// into notes_text. A bare "[amend " literal could appear in
+// user-authored notes content (a quoted example, a code snippet), so
+// the heuristic anchors on the timestamp shape the amend command
+// actually emits:
+//
+// \n[amend YYYY-MM-DDTHH:MMZ]: ...
+//
+// The anchor accepts the marker either after a newline (the
+// COALESCE-append UPDATE path) or at byte 0: the first-ever amend on
+// an unseeded family takes AppendPlaybookNotes' INSERT path, which
+// trims the marker's leading newlines before storing, so the row's
+// notes_text starts with "[amend " at byte 0. Both shapes are
+// agent-authored and must be preserved across re-seeds; \d{4}- pins
+// the year prefix so prose like "the [amend foo] flag" never matches.
+var amendMarkerRe = regexp.MustCompile(`(?:^|\n)\[amend \d{4}-`)
+
+// playbookInitOnce gates runPlaybookInitOnce so seeding happens at
+// most once per CLI process.
+var playbookInitOnce sync.Once
+
+// runPlaybookInitOnce opens the default DB and seeds
+// learning_playbooks from the embedded JSON+MD pairs in
+// playbooks.FS. Idempotent: re-runs short-circuit when the sentinel
+// row's seed version matches playbooks.SeedVersion. Failures
+// downgrade to a stderr warning; the CLI continues without seeded
+// playbooks.
+func runPlaybookInitOnce(ctx context.Context) {
+ playbookInitOnce.Do(func() {
+ dbPath := defaultDBPath("human-goat-pp-cli")
+ s, err := store.OpenWithContext(ctx, dbPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: playbook init: open store: %v\n", err)
+ return
+ }
+ defer s.Close()
+ if err := installPlaybooksFromEmbed(ctx, s, playbooks.FS); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: playbook init: %v\n", err)
+ }
+ })
+}
+
+// installPlaybooksFromEmbed walks the provided fs.FS for JSON+notes
+// pairs and seeds each via store.UpsertPlaybook. The sentinel row
+// tracks the current seed version; re-seeding only happens when the
+// binary version bumps. Per-file parse failures log to stderr and
+// are skipped. The sentinel row is only written when every per-file
+// upsert succeeds — partial failures leave the sentinel untouched so
+// the next install retries.
+//
+// The function takes an fs.FS rather than reading playbooks.FS
+// directly so tests can inject an fstest.MapFS with scenario-
+// specific fixtures. Production callers pass playbooks.FS.
+func installPlaybooksFromEmbed(ctx context.Context, s *store.Store, embedFS fs.FS) error {
+ // Honor ctx for cancellation at entry so a canceled context
+ // short-circuits before any DB work.
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ // Sentinel check: skip if seed version matches what's already installed.
+ if existing, ok, err := s.GetPlaybookByFamily(playbookSeedSentinelFamily); err == nil && ok && existing.NotesText == playbooks.SeedVersion {
+ return nil
+ }
+
+ entries, err := fs.ReadDir(embedFS, ".")
+ if err != nil {
+ return fmt.Errorf("read embed dir: %w", err)
+ }
+ jsonBases := make(map[string]bool, len(entries))
+ notesBases := make(map[string]string, len(entries))
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ switch {
+ case strings.HasSuffix(name, "_notes.md"):
+ base := strings.TrimSuffix(name, "_notes.md")
+ notesBases[base] = name
+ case strings.HasSuffix(name, ".json"):
+ base := strings.TrimSuffix(name, ".json")
+ jsonBases[base] = true
+ }
+ }
+
+ // Sort bases for deterministic seed order (matters for audit log).
+ bases := make([]string, 0, len(jsonBases))
+ for b := range jsonBases {
+ bases = append(bases, b)
+ }
+ sort.Strings(bases)
+
+ learnCfg := newLearnConfig()
+
+ // Track whether any per-file upsert (or parse, or marshal, or
+ // read) failed. The sentinel row is only written when every base
+ // succeeded; this ensures that a partial failure leaves the
+ // sentinel stale and the next install retries.
+ upsertFailed := false
+
+ for _, base := range bases {
+ // Honor ctx between bases so a long install can be canceled
+ // mid-loop without completing.
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ jsonName := base + ".json"
+ jsonData, rerr := fs.ReadFile(embedFS, jsonName)
+ if rerr != nil {
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: playbook init: read %s: %v\n", jsonName, rerr)
+ upsertFailed = true
+ continue
+ }
+ pb, perr := learn.ParsePlaybook(jsonData, jsonName)
+ if perr != nil {
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: playbook init: parse %s: %v\n", jsonName, perr)
+ upsertFailed = true
+ continue
+ }
+ // Derive ALL distinct query families from the example queries.
+ // One playbook may cover multiple families (different phrasings
+ // of the same intent normalize to different families). Seed
+ // under each distinct family so any example phrasing hits the
+ // same playbook+notes.
+ families := make(map[string]bool)
+ if len(pb.QueryFamilyExamples) > 0 {
+ for _, ex := range pb.QueryFamilyExamples {
+ normalized := learn.Normalize(ex, learnCfg)
+ fam := learn.QueryFamily(normalized)
+ if fam != "" {
+ families[fam] = true
+ }
+ }
+ }
+ if len(families) == 0 {
+ // Without query_family_examples we have no way to compute
+ // a family key that `recall` would ever match — QueryFamily
+ // returns a space-separated bag of non-entity tokens, and
+ // the underscore-delimited filename stem can't reproduce
+ // that shape. Refuse to seed under an unreachable key; the
+ // authored embed must supply at least one example query.
+ // Policy skip — does NOT flip upsertFailed; the install
+ // is still considered a clean pass.
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: playbook init: %s has no query_family_examples; skipping (would be unreachable at recall time)\n", jsonName)
+ continue
+ }
+ var notesText string
+ if notesName, ok := notesBases[base]; ok {
+ data, nerr := fs.ReadFile(embedFS, notesName)
+ if nerr != nil {
+ // A failed notes read is treated like every other
+ // error in this loop: log it and mark the seed as
+ // failed so the sentinel is NOT advanced and the next
+ // invocation retries. Silently seeding empty notes
+ // here would lock out the retry behind a sentinel.
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: playbook init: read %s: %v\n", notesName, nerr)
+ upsertFailed = true
+ continue
+ }
+ notesText = string(data)
+ }
+ jsonStr, merr := learn.MarshalPlaybook(pb)
+ if merr != nil {
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: playbook init: marshal %s: %v\n", jsonName, merr)
+ upsertFailed = true
+ continue
+ }
+ // Sort families for deterministic install order.
+ famList := make([]string, 0, len(families))
+ for f := range families {
+ famList = append(famList, f)
+ }
+ sort.Strings(famList)
+ for _, family := range famList {
+ // Two competing goals on re-seed:
+ // 1. SeedVersion bumps must deliver corrected notes
+ // content to existing installs (the whole point of
+ // bumping the version).
+ // 2. Notes that `playbook amend` wrote at runtime must
+ // survive binary upgrades — they encode agent-observed
+ // gotchas we don't want to lose.
+ //
+ // The amend command appends a "[amend YYYY-MM-DDTHH:MMZ]:"
+ // marker, which is the durable signal that a row has agent
+ // content. If the stored notes carry that marker (matched
+ // via amendMarkerRe's newline-or-start + year-prefix
+ // anchor), preserve them; otherwise overwrite with the
+ // freshly-embedded notes so the SeedVersion bump actually
+ // ships the content corrections.
+ preserve := false
+ if existing, ok, gerr := s.GetPlaybookByFamily(family); gerr == nil && ok {
+ if amendMarkerRe.MatchString(existing.NotesText) {
+ preserve = true
+ }
+ }
+ if _, _, uerr := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: family,
+ PlaybookJSON: jsonStr,
+ NotesText: notesText,
+ Source: store.LearningSourceTaught,
+ PreserveExistingNotes: preserve,
+ }); uerr != nil {
+ fmt.Fprintf(os.Stderr, "warning: human-goat-pp-cli: playbook init: upsert family=%q for %s: %v\n", family, jsonName, uerr)
+ upsertFailed = true
+ continue
+ }
+ }
+ }
+
+ // Sentinel is written only when every per-file upsert succeeded.
+ // A partial failure leaves the sentinel stale so the next CLI
+ // invocation retries.
+ if upsertFailed {
+ return fmt.Errorf("playbook init: one or more per-playbook upserts failed; sentinel not updated, install will retry")
+ }
+
+ // Sentinel update marks this seed version as installed.
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: playbookSeedSentinelFamily,
+ NotesText: playbooks.SeedVersion,
+ Source: "seeded",
+ }); err != nil {
+ return fmt.Errorf("update sentinel: %w", err)
+ }
+ return nil
+}
diff --git a/library/productivity/human-goat/internal/cli/playbook_init_test.go b/library/productivity/human-goat/internal/cli/playbook_init_test.go
new file mode 100644
index 0000000000..dd344d1675
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/playbook_init_test.go
@@ -0,0 +1,536 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// playbook_init_test.go exercises the embed-FS auto-install path.
+// Tests inject an fstest.MapFS rather than reading the real
+// playbooks.FS so scenarios are scoped to the file under test (and
+// authored playbook content can land independently of the install-
+// path contract).
+
+package cli
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "testing/fstest"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cli/playbooks"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// twoPlaybookFS returns an fstest.MapFS with two minimal playbooks +
+// notes pairs. Both have query_family_examples that normalize to
+// non-empty families, so installPlaybooksFromEmbed will upsert under
+// each.
+func twoPlaybookFS() fstest.MapFS {
+ return fstest.MapFS{
+ "alpha.json": &fstest.MapFile{
+ Data: []byte(`{
+ "query_family_examples": ["alpha example query", "alpha second phrasing"],
+ "steps": [{"cmd": "human-goat-pp-cli --help", "purpose": "noop"}],
+ "entity_slots": [],
+ "expected_tool_calls": 1
+}`),
+ },
+ "alpha_notes.md": &fstest.MapFile{
+ Data: []byte("# alpha notes\n\nAlpha-specific gotcha.\n"),
+ },
+ "beta.json": &fstest.MapFile{
+ Data: []byte(`{
+ "query_family_examples": ["beta example query", "beta second phrasing"],
+ "steps": [{"cmd": "human-goat-pp-cli --help", "purpose": "noop"}],
+ "entity_slots": [],
+ "expected_tool_calls": 1
+}`),
+ },
+ "beta_notes.md": &fstest.MapFile{
+ Data: []byte("# beta notes\n\nBeta-specific gotcha.\n"),
+ },
+ }
+}
+
+// TestPlaybookInit_SeedsAllPlaybooks pins that the fresh-install path
+// seeds every authored JSON+notes pair and writes the sentinel row.
+func TestPlaybookInit_SeedsAllPlaybooks(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ if err := installPlaybooksFromEmbed(context.Background(), s, twoPlaybookFS()); err != nil {
+ t.Fatalf("install: %v", err)
+ }
+
+ rows, err := s.ListPlaybooks()
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(rows) < 2 {
+ t.Errorf("expected at least 2 playbook rows; got %d", len(rows))
+ }
+ for _, r := range rows {
+ if r.QueryFamily == playbookSeedSentinelFamily {
+ t.Errorf("ListPlaybooks should hide the seed sentinel; got family=%q", r.QueryFamily)
+ }
+ }
+
+ sentinel, ok, err := s.GetPlaybookByFamily(playbookSeedSentinelFamily)
+ if err != nil {
+ t.Fatalf("get sentinel: %v", err)
+ }
+ if !ok {
+ t.Fatal("sentinel row missing after install")
+ }
+ if sentinel.NotesText != playbooks.SeedVersion {
+ t.Errorf("sentinel notes_text = %q, want %q", sentinel.NotesText, playbooks.SeedVersion)
+ }
+
+ // Notes content should be present on the seeded rows.
+ hasAlphaNotes := false
+ hasBetaNotes := false
+ for _, r := range rows {
+ if strings.Contains(r.NotesText, "alpha notes") {
+ hasAlphaNotes = true
+ }
+ if strings.Contains(r.NotesText, "beta notes") {
+ hasBetaNotes = true
+ }
+ }
+ if !hasAlphaNotes {
+ t.Error("expected alpha notes to be seeded")
+ }
+ if !hasBetaNotes {
+ t.Error("expected beta notes to be seeded")
+ }
+}
+
+// TestPlaybookInit_Idempotent pins that running install twice produces
+// zero drift in row count.
+func TestPlaybookInit_Idempotent(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ embedFS := twoPlaybookFS()
+ if err := installPlaybooksFromEmbed(context.Background(), s, embedFS); err != nil {
+ t.Fatalf("first install: %v", err)
+ }
+ firstRows, _ := s.ListPlaybooks()
+ if err := installPlaybooksFromEmbed(context.Background(), s, embedFS); err != nil {
+ t.Fatalf("second install: %v", err)
+ }
+ secondRows, _ := s.ListPlaybooks()
+ if len(firstRows) != len(secondRows) {
+ t.Errorf("re-install drifted: first=%d second=%d", len(firstRows), len(secondRows))
+ }
+}
+
+// TestPlaybookInit_ConcurrentSafe pins that concurrent installs land
+// exactly one sentinel row and no duplicate per-family rows.
+func TestPlaybookInit_ConcurrentSafe(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ embedFS := twoPlaybookFS()
+ var wg sync.WaitGroup
+ for i := 0; i < 5; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _ = installPlaybooksFromEmbed(context.Background(), s, embedFS)
+ }()
+ }
+ wg.Wait()
+
+ if _, ok, err := s.GetPlaybookByFamily(playbookSeedSentinelFamily); err != nil {
+ t.Fatalf("get sentinel: %v", err)
+ } else if !ok {
+ t.Error("expected sentinel row after concurrent installs, got none")
+ }
+
+ rows, err := s.ListPlaybooks()
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ seen := make(map[string]int)
+ for _, r := range rows {
+ seen[r.QueryFamily]++
+ }
+ for fam, count := range seen {
+ if count > 1 {
+ t.Errorf("duplicate row for family=%q (count=%d)", fam, count)
+ }
+ }
+}
+
+// TestPlaybookInit_ReseedReplacesNotesWithoutAmend pins that a
+// SeedVersion bump replaces stored notes_text when no `[amend ...]`
+// marker is present — otherwise the bump would have no effect on
+// existing users.
+func TestPlaybookInit_ReseedReplacesNotesWithoutAmend(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ embedFS := twoPlaybookFS()
+ if err := installPlaybooksFromEmbed(context.Background(), s, embedFS); err != nil {
+ t.Fatalf("first install: %v", err)
+ }
+
+ rows, _ := s.ListPlaybooks()
+ var target string
+ for _, r := range rows {
+ if r.NotesText != "" {
+ target = r.QueryFamily
+ break
+ }
+ }
+ if target == "" {
+ t.Fatal("no seeded row with notes to mutate")
+ }
+
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: target,
+ PlaybookJSON: "{}",
+ NotesText: "STALE PRE-CORRECTION CONTENT",
+ Source: store.LearningSourceTaught,
+ }); err != nil {
+ t.Fatalf("seed stale notes: %v", err)
+ }
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: playbookSeedSentinelFamily,
+ NotesText: "old-version",
+ Source: "seeded",
+ }); err != nil {
+ t.Fatalf("reset sentinel: %v", err)
+ }
+
+ if err := installPlaybooksFromEmbed(context.Background(), s, embedFS); err != nil {
+ t.Fatalf("re-install: %v", err)
+ }
+
+ got, _, _ := s.GetPlaybookByFamily(target)
+ if got.NotesText == "STALE PRE-CORRECTION CONTENT" {
+ t.Errorf("re-seed should overwrite stale notes without amend marker; still got STALE content")
+ }
+}
+
+// TestPlaybookInit_ReseedPreservesNotesWithAmend pins the complementary
+// path: notes containing a `[amend ...]` marker survive a SeedVersion
+// bump so agent-authored content isn't lost.
+func TestPlaybookInit_ReseedPreservesNotesWithAmend(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ embedFS := twoPlaybookFS()
+ if err := installPlaybooksFromEmbed(context.Background(), s, embedFS); err != nil {
+ t.Fatalf("first install: %v", err)
+ }
+
+ rows, _ := s.ListPlaybooks()
+ var target string
+ for _, r := range rows {
+ if r.NotesText != "" {
+ target = r.QueryFamily
+ break
+ }
+ }
+ if target == "" {
+ t.Fatal("no seeded row to mutate")
+ }
+
+ // Canonical amend marker shape: leading newline + bracketed amend
+ // tag with year-prefixed timestamp. amendMarkerRe must fire.
+ amended := "base content\n[amend 2026-05-27T03:14Z]: agent gotcha"
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: target,
+ PlaybookJSON: "{}",
+ NotesText: amended,
+ Source: store.LearningSourceTaught,
+ }); err != nil {
+ t.Fatalf("seed amended notes: %v", err)
+ }
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: playbookSeedSentinelFamily,
+ NotesText: "old-version",
+ Source: "seeded",
+ }); err != nil {
+ t.Fatalf("reset sentinel: %v", err)
+ }
+
+ if err := installPlaybooksFromEmbed(context.Background(), s, embedFS); err != nil {
+ t.Fatalf("re-install: %v", err)
+ }
+
+ got, _, _ := s.GetPlaybookByFamily(target)
+ if got.NotesText != amended {
+ t.Errorf("re-seed should preserve notes containing [amend ...] marker;\n got %q\nwant %q",
+ firstNChars(got.NotesText, 200), amended)
+ }
+}
+
+// TestPlaybookInit_ReseedPreservesFirstInsertAmend pins the INSERT-path
+// variant of amend preservation: the first-ever `playbook amend` on a
+// family with no existing row takes AppendPlaybookNotes' INSERT path,
+// which trims the marker's leading newlines, so the stored notes_text
+// begins with "[amend " at byte 0 (no preceding newline). When a later
+// SeedVersion bump ships an embed JSON normalizing to that same
+// family, the preserve check must still recognize the row as
+// agent-authored and keep the notes.
+func TestPlaybookInit_ReseedPreservesFirstInsertAmend(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ // First install ships only alpha; beta's family has no row yet.
+ full := twoPlaybookFS()
+ alphaOnly := fstest.MapFS{
+ "alpha.json": full["alpha.json"],
+ "alpha_notes.md": full["alpha_notes.md"],
+ }
+ if err := installPlaybooksFromEmbed(context.Background(), s, alphaOnly); err != nil {
+ t.Fatalf("first install: %v", err)
+ }
+
+ // Compute beta's family exactly the way the installer does, then
+ // amend it. The row does not exist, so this takes the INSERT path
+ // and stores the marker with leading newlines trimmed.
+ betaFamily := learn.QueryFamily(learn.Normalize("beta example query", newLearnConfig()))
+ if betaFamily == "" {
+ t.Fatal("beta example query normalized to empty family")
+ }
+ marker := "\n\n[amend 2026-05-27T03:14Z]: hard-won agent gotcha"
+ if _, inserted, err := s.AppendPlaybookNotes(betaFamily, marker); err != nil {
+ t.Fatalf("amend: %v", err)
+ } else if !inserted {
+ t.Fatal("expected INSERT path (no pre-existing beta row)")
+ }
+
+ // Binary upgrade: SeedVersion bump ships a beta embed normalizing
+ // to the amended family.
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: playbookSeedSentinelFamily,
+ NotesText: "old-version",
+ Source: "seeded",
+ }); err != nil {
+ t.Fatalf("reset sentinel: %v", err)
+ }
+ if err := installPlaybooksFromEmbed(context.Background(), s, full); err != nil {
+ t.Fatalf("re-install: %v", err)
+ }
+
+ got, ok, err := s.GetPlaybookByFamily(betaFamily)
+ if err != nil || !ok {
+ t.Fatalf("get beta family after re-seed: ok=%v err=%v", ok, err)
+ }
+ if !strings.Contains(got.NotesText, "hard-won agent gotcha") {
+ t.Errorf("re-seed overwrote first-insert amend notes;\n got %q", firstNChars(got.NotesText, 200))
+ }
+}
+
+// TestPlaybookInit_AmendMarkerSpecificity (round-4 hygiene #2) ensures
+// the regex does NOT match a literal "[amend " token that appears in
+// user-authored notes content without the leading-newline + timestamp
+// anchor. A bare `strings.Contains` would mis-fire on prose like
+// "the [amend foo] flag" — the regex anchor refuses that shape.
+func TestPlaybookInit_AmendMarkerSpecificity(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ embedFS := twoPlaybookFS()
+ if err := installPlaybooksFromEmbed(context.Background(), s, embedFS); err != nil {
+ t.Fatalf("first install: %v", err)
+ }
+
+ rows, _ := s.ListPlaybooks()
+ var target string
+ for _, r := range rows {
+ if r.NotesText != "" {
+ target = r.QueryFamily
+ break
+ }
+ }
+ if target == "" {
+ t.Fatal("no seeded row to mutate")
+ }
+
+ // User-authored prose containing "[amend " but lacking the
+ // leading-newline + year-prefix anchor. amendMarkerRe must NOT
+ // fire, so the re-seed overwrites this content.
+ loose := "this note explains how the [amend foo] flag works in some other tool"
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: target,
+ PlaybookJSON: "{}",
+ NotesText: loose,
+ Source: store.LearningSourceTaught,
+ }); err != nil {
+ t.Fatalf("seed loose notes: %v", err)
+ }
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: playbookSeedSentinelFamily,
+ NotesText: "old-version",
+ Source: "seeded",
+ }); err != nil {
+ t.Fatalf("reset sentinel: %v", err)
+ }
+
+ if err := installPlaybooksFromEmbed(context.Background(), s, embedFS); err != nil {
+ t.Fatalf("re-install: %v", err)
+ }
+
+ got, _, _ := s.GetPlaybookByFamily(target)
+ if got.NotesText == loose {
+ t.Errorf("re-seed should overwrite loose [amend literal that lacks timestamp anchor; got preserved content")
+ }
+}
+
+// TestPlaybookInit_SkipsPlaybookWithoutExamples pins the edge case from
+// Greptile round 2: a JSON with no query_family_examples is unreachable
+// at recall time, so the install path refuses to seed it and emits a
+// stderr warning. The sentinel still writes because the skip is
+// graceful (policy skips don't fail the install).
+func TestPlaybookInit_SkipsPlaybookWithoutExamples(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ noExamplesFS := fstest.MapFS{
+ "empty_examples.json": &fstest.MapFile{
+ Data: []byte(`{
+ "steps": [{"cmd": "human-goat-pp-cli --help", "purpose": "no-op"}],
+ "entity_slots": [],
+ "expected_tool_calls": 1
+}`),
+ },
+ }
+ if err := installPlaybooksFromEmbed(context.Background(), s, noExamplesFS); err != nil {
+ t.Fatalf("install: %v", err)
+ }
+
+ rows, _ := s.ListPlaybooks()
+ if len(rows) != 0 {
+ t.Errorf("expected 0 user-facing rows (skip unreachable); got %d", len(rows))
+ }
+ if _, ok, _ := s.GetPlaybookByFamily(playbookSeedSentinelFamily); !ok {
+ t.Error("expected sentinel row after clean install with policy-skipped playbook")
+ }
+}
+
+// TestPlaybookInit_FailureLeavesSentinelStale (round-4 hygiene #3)
+// covers: if any per-playbook upsert (or parse, or read) fails, the
+// sentinel must NOT update so the next install retries. We exercise
+// the parse-failure path because store.UpsertPlaybook is hard to make
+// fail in-process; a JSON syntax error flips upsertFailed = true.
+func TestPlaybookInit_FailureLeavesSentinelStale(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ brokenFS := fstest.MapFS{
+ "broken.json": &fstest.MapFile{
+ Data: []byte(`{ this is not valid json `),
+ },
+ // A valid playbook alongside the broken one — the loop must
+ // continue across the broken file but still flag the overall
+ // install as failed so the sentinel doesn't update.
+ "valid.json": &fstest.MapFile{
+ Data: []byte(`{
+ "query_family_examples": ["valid query family example"],
+ "steps": [{"cmd": "human-goat-pp-cli --help", "purpose": "noop"}],
+ "entity_slots": [],
+ "expected_tool_calls": 1
+}`),
+ },
+ }
+ err = installPlaybooksFromEmbed(context.Background(), s, brokenFS)
+ if err == nil {
+ t.Fatal("expected install to return error when a per-file parse failed")
+ }
+ if !strings.Contains(err.Error(), "sentinel not updated") {
+ t.Errorf("error should mention sentinel-not-updated; got %v", err)
+ }
+
+ if _, ok, _ := s.GetPlaybookByFamily(playbookSeedSentinelFamily); ok {
+ t.Error("sentinel should NOT exist after a per-file failure; got a row")
+ }
+}
+
+// TestPlaybookInit_HonorsContextCancel (round-4 hygiene #1) pins that
+// ctx is honored at install entry. A canceled context short-circuits
+// before any DB work, returning context.Canceled and leaving the
+// sentinel untouched.
+func TestPlaybookInit_HonorsContextCancel(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "data.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err = installPlaybooksFromEmbed(ctx, s, twoPlaybookFS())
+ if err == nil {
+ t.Fatal("expected install to return error on canceled context")
+ }
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("expected context.Canceled, got %v", err)
+ }
+ if _, ok, _ := s.GetPlaybookByFamily(playbookSeedSentinelFamily); ok {
+ t.Error("sentinel should NOT exist after canceled install")
+ }
+}
+
+// firstNChars trims a string for test error messages; named to avoid
+// colliding with other helpers that share the firstN... prefix.
+func firstNChars(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "..."
+}
diff --git a/library/productivity/human-goat/internal/cli/playbooks/MANIFEST.md b/library/productivity/human-goat/internal/cli/playbooks/MANIFEST.md
new file mode 100644
index 0000000000..e0b4ae2fdf
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/playbooks/MANIFEST.md
@@ -0,0 +1,21 @@
+# Playbooks for this CLI
+
+This directory holds hand-authored playbook content embedded into the
+binary. Each playbook is a JSON file (CLI choreography) plus a
+`_notes.md` file (free-text gotchas / workarounds). Both ship as
+embedded data and auto-install into the `learning_playbooks` table
+at first DB open.
+
+## Adding a playbook
+
+1. Create `.json` matching the `learn.Playbook` shape.
+2. Create matching `_notes.md` with workarounds.
+3. Bump `SeedVersion` in `embed.go` so existing installs re-seed.
+
+## Convention
+
+- One playbook per query family. Use `queryStructural` to predict the
+ family-key collision.
+- Notes are markdown; the agent reads them verbatim per SKILL.md.
+- This `MANIFEST.md` stub keeps `//go:embed *.md` matching at least one
+ file even when no playbook content exists. Do not delete it.
diff --git a/library/productivity/human-goat/internal/cli/playbooks/embed.go b/library/productivity/human-goat/internal/cli/playbooks/embed.go
new file mode 100644
index 0000000000..8aaabe3ccc
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/playbooks/embed.go
@@ -0,0 +1,36 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package playbooks ships the per-CLI playbook + notes content as an
+// embedded filesystem. The auto-install path in
+// internal/cli/playbook_init.go reads from FS at first DB open and
+// seeds the learning_playbooks table.
+//
+// Convention (designed to copy cleanly to every CLI):
+// - .json holds the steps + entity_slots + expected_tool_calls
+// - _notes.md holds gotchas / workarounds (read verbatim
+// by the agent at recall time)
+// - MANIFEST.md keeps //go:embed *.md matching at least one file
+// even when no playbook content exists
+//
+// Bump SeedVersion when the embedded content changes so existing
+// installs re-seed on the next CLI invocation.
+//
+// When shipping JSON playbook content, extend the //go:embed directive
+// below to "*.json *.md" so the JSON files are bundled into FS.
+
+package playbooks
+
+import "embed"
+
+//go:embed *.md
+var FS embed.FS
+
+// SeedVersion identifies the playbook content version. Embedded by
+// the install path as a sentinel row; mismatch triggers re-seed.
+// Format: --.
+//
+// The generator ships the initial placeholder. Bump this when shipping
+// authored playbook content (JSON + notes files alongside this file)
+// so existing installs re-seed on the next CLI invocation.
+var SeedVersion = "human-goat-initial-001"
diff --git a/library/productivity/human-goat/internal/cli/profile.go b/library/productivity/human-goat/internal/cli/profile.go
new file mode 100644
index 0000000000..0f32002ef5
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/profile.go
@@ -0,0 +1,370 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+// Profile is a named set of flag values saved for reuse across invocations.
+// HeyGen's "Beacon" pattern: one named context that a scheduled agent reuses
+// day after day with the same voice/format but different input each run.
+type Profile struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Values map[string]string `json:"values"`
+}
+
+type profileStore struct {
+ Profiles map[string]Profile `json:"profiles"`
+}
+
+func profileStorePath() (string, error) {
+ dir, err := cliutil.ConfigDir()
+ if err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("creating profile config dir: %w", err)
+ }
+ return filepath.Join(dir, "profiles.json"), nil
+}
+
+func legacyProfileStorePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home dir: %w", err)
+ }
+ dir := filepath.Join(home, ".human-goat-pp-cli")
+ return filepath.Join(dir, "profiles.json"), nil
+}
+
+func loadProfileStore() (*profileStore, error) {
+ p, err := profileStorePath()
+ if err != nil {
+ return nil, err
+ }
+ legacy, legacyErr := legacyProfileStorePath()
+ if legacyErr != nil || legacy == p {
+ legacy = ""
+ }
+ data, sourcePath, err := cliutil.ReadFileWithLegacyFallback(p, legacy)
+ if err != nil {
+ if os.IsNotExist(err) || sourcePath == legacy {
+ return &profileStore{Profiles: map[string]Profile{}}, nil
+ }
+ return nil, fmt.Errorf("reading profiles: %w", err)
+ }
+ var s profileStore
+ if err := json.Unmarshal(data, &s); err != nil {
+ return nil, fmt.Errorf("parsing profiles: %w", err)
+ }
+ if s.Profiles == nil {
+ s.Profiles = map[string]Profile{}
+ }
+ return &s, nil
+}
+
+func saveProfileStore(s *profileStore) error {
+ p, err := profileStorePath()
+ if err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(s, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling profiles: %w", err)
+ }
+ tmp := p + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
+ return fmt.Errorf("writing profiles: %w", err)
+ }
+ return os.Rename(tmp, p)
+}
+
+// GetProfile returns a profile by name, or (nil, nil) if not found.
+func GetProfile(name string) (*Profile, error) {
+ s, err := loadProfileStore()
+ if err != nil {
+ return nil, err
+ }
+ if p, ok := s.Profiles[name]; ok {
+ return &p, nil
+ }
+ return nil, nil
+}
+
+// ApplyProfileToFlags overlays profile values onto flags that the user has
+// not set explicitly on the command line. Used from root.go's
+// PersistentPreRunE so profile values feed the whole command tree.
+func ApplyProfileToFlags(cmd *cobra.Command, profile *Profile) error {
+ if profile == nil || len(profile.Values) == 0 {
+ return nil
+ }
+ // Reserved flags that never come from a profile - they control profile
+ // resolution itself or are dangerous to overlay. profile save's skip
+ // map must remain a superset of this set so saved profiles never carry
+ // values that apply would silently refuse.
+ reserved := map[string]bool{
+ "profile": true, "config": true, "home": true, "help": true,
+ }
+ for name, value := range profile.Values {
+ if reserved[name] {
+ continue
+ }
+ flag := cmd.Flags().Lookup(name)
+ if flag == nil {
+ flag = cmd.InheritedFlags().Lookup(name)
+ }
+ if flag == nil {
+ continue
+ }
+ if flag.Changed {
+ continue
+ }
+ if err := flag.Value.Set(value); err != nil {
+ return fmt.Errorf("applying profile value %s=%q: %w", name, value, err)
+ }
+ }
+ return nil
+}
+
+// ListProfileNames returns profile names sorted alphabetically. Used by the
+// agent-context subcommand to expose available_profiles at runtime.
+func ListProfileNames() []string {
+ s, err := loadProfileStore()
+ if err != nil {
+ return nil
+ }
+ names := make([]string, 0, len(s.Profiles))
+ for name := range s.Profiles {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
+
+func newProfileCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "profile",
+ Short: "Named sets of flags saved for reuse",
+ Long: `Profiles capture a set of flag values under a name so a scheduled
+agent can invoke the same command with the same configuration each run.
+
+ profile save captures the current invocation's set flags
+ profile use prints the values (for inspection)
+ profile list lists all saved profiles
+ profile show shows the values of one profile
+ profile delete removes a profile
+
+Use --profile on any command to apply that profile's values.
+Explicit flags override profile values.`,
+ RunE: parentNoSubcommandRunE(flags),
+ }
+ cmd.AddCommand(newProfileSaveCmd(flags))
+ cmd.AddCommand(newProfileUseCmd(flags))
+ cmd.AddCommand(newProfileListCmd(flags))
+ cmd.AddCommand(newProfileShowCmd(flags))
+ cmd.AddCommand(newProfileDeleteCmd(flags))
+ return cmd
+}
+
+func newProfileSaveCmd(flags *rootFlags) *cobra.Command {
+ var description string
+ cmd := &cobra.Command{
+ Use: "save [-- ...]",
+ Short: "Save the current invocation's non-default flags as a named profile",
+ Long: `Captures every flag explicitly set on the invocation and stores
+them under . To update an existing profile, run save again; the
+entry is replaced.
+
+To avoid creating empty profiles, at least one non-default flag must be
+present (other than --profile, --config, and --home, which are never
+captured: they control profile/config resolution and would never apply).`,
+ Example: ` human-goat-pp-cli profile save my-defaults --json --compact
+ human-goat-pp-cli profile save tonight-defaults --region US`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ name := args[0]
+ if strings.ContainsAny(name, `/\: `) {
+ return fmt.Errorf("profile name %q contains reserved characters", name)
+ }
+ values := map[string]string{}
+ // Walk inherited + local flags, capture only those the user set.
+ // Must stay a superset of ApplyProfileToFlags' reserved map for
+ // root flags: capturing a flag that apply refuses to overlay
+ // would store values that never take effect.
+ skip := map[string]bool{"profile": true, "config": true, "home": true, "help": true, "description": true}
+ visit := func(fl *pflag.Flag) {
+ if fl.Changed && !skip[fl.Name] {
+ values[fl.Name] = fl.Value.String()
+ }
+ }
+ cmd.InheritedFlags().VisitAll(visit)
+ cmd.Flags().VisitAll(visit)
+ if len(values) == 0 {
+ return fmt.Errorf("no non-default flags set - pass at least one flag to save into %q", name)
+ }
+ s, err := loadProfileStore()
+ if err != nil {
+ return err
+ }
+ s.Profiles[name] = Profile{Name: name, Description: description, Values: values}
+ if err := saveProfileStore(s); err != nil {
+ return err
+ }
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), s.Profiles[name], flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "saved profile %q with %d values\n", name, len(values))
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&description, "description", "", "Short description shown in 'profile list'")
+ return cmd
+}
+
+func newProfileUseCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "use ",
+ Short: "Print the flag values a profile will apply (does not execute anything)",
+ Example: ` human-goat-pp-cli profile use my-defaults
+ human-goat-pp-cli profile use tonight-defaults --json`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ p, err := GetProfile(args[0])
+ if err != nil {
+ return err
+ }
+ if p == nil {
+ return fmt.Errorf("profile %q not found", args[0])
+ }
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), p, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "profile %q:\n", p.Name)
+ if p.Description != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), " description: %s\n", p.Description)
+ }
+ keys := make([]string, 0, len(p.Values))
+ for k := range p.Values {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ fmt.Fprintf(cmd.OutOrStdout(), " --%s %s\n", k, p.Values[k])
+ }
+ return nil
+ },
+ }
+}
+
+func newProfileListCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "list",
+ Short: "List saved profiles",
+ Annotations: map[string]string{
+ "mcp:read-only": "true",
+ },
+ Example: ` human-goat-pp-cli profile list
+ human-goat-pp-cli profile list --json`,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ s, err := loadProfileStore()
+ if err != nil {
+ return err
+ }
+ names := make([]string, 0, len(s.Profiles))
+ for n := range s.Profiles {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ if flags.asJSON {
+ out := make([]map[string]any, 0, len(names))
+ for _, n := range names {
+ p := s.Profiles[n]
+ out = append(out, map[string]any{
+ "name": p.Name,
+ "description": p.Description,
+ "field_count": len(p.Values),
+ })
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ headers := []string{"NAME", "FIELDS", "DESCRIPTION"}
+ rows := make([][]string, 0, len(names))
+ for _, n := range names {
+ p := s.Profiles[n]
+ rows = append(rows, []string{p.Name, fmt.Sprintf("%d", len(p.Values)), p.Description})
+ }
+ return flags.printTable(cmd, headers, rows)
+ },
+ }
+}
+
+func newProfileShowCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "show ",
+ Short: "Show a profile's values as JSON",
+ Annotations: map[string]string{
+ "mcp:read-only": "true",
+ },
+ Example: ` human-goat-pp-cli profile show my-defaults
+ human-goat-pp-cli profile show tonight-defaults --json`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ p, err := GetProfile(args[0])
+ if err != nil {
+ return err
+ }
+ if p == nil {
+ return fmt.Errorf("profile %q not found", args[0])
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), p, flags)
+ },
+ }
+}
+
+func newProfileDeleteCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "delete ",
+ Short: "Remove a profile",
+ Example: ` human-goat-pp-cli profile delete my-defaults --yes
+ human-goat-pp-cli profile delete old-profile --yes --json`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ name := args[0]
+ s, err := loadProfileStore()
+ if err != nil {
+ return err
+ }
+ if _, ok := s.Profiles[name]; !ok {
+ return fmt.Errorf("profile %q not found", name)
+ }
+ if !flags.yes {
+ fmt.Fprintf(cmd.ErrOrStderr(), "refusing to delete %q without --yes\n", name)
+ return fmt.Errorf("confirmation required: pass --yes")
+ }
+ delete(s.Profiles, name)
+ if err := saveProfileStore(s); err != nil {
+ return err
+ }
+ // JSON envelope: {deleted: name}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "deleted": name,
+ }, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "deleted profile %q\n", name)
+ return nil
+ },
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/promoted_account.go b/library/productivity/human-goat/internal/cli/promoted_account.go
new file mode 100644
index 0000000000..9e29d84e4e
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/promoted_account.go
@@ -0,0 +1,84 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newAccountPromotedCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "account",
+ Short: "Get the authenticated account profile",
+ Long: "Get the authenticated account profile",
+ Example: " human-goat-pp-cli account",
+ Annotations: map[string]string{"pp:endpoint": "account.get", "pp:method": "GET", "pp:path": "/api/v3/account.json", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/api/v3/account.json"
+ params := map[string]string{}
+ data, prov, err := resolveReadWithStrategyAndResponsePath(cmd.Context(), c, flags, "auto", "account", false, path, params, nil, "", cmd.ErrOrStderr())
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_endpoint.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ if json.Unmarshal(data, &countItems) != nil {
+ // Single object, not an array
+ countItems = []json.RawMessage{data}
+ }
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope. --select wins over
+ // --compact when both are set; --compact only runs when no explicit
+ // fields were requested. Explicit format flags (--csv, --quiet, --plain)
+ // opt out of the auto-JSON path so piped consumers that asked for a
+ // non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlagsMeta(cmd.OutOrStdout(), data, flags, map[string]any{"source": "live"})
+ },
+ }
+
+ // Wire sibling endpoints and sub-resources as subcommands
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/promoted_categories.go b/library/productivity/human-goat/internal/cli/promoted_categories.go
new file mode 100644
index 0000000000..9face8a13a
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/promoted_categories.go
@@ -0,0 +1,84 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newCategoriesPromotedCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "categories",
+ Short: "List task templates for the account metro (title, category_name, category_id, default_template_id, top_category)",
+ Long: "List task templates for the account metro (title, category_name, category_id, default_template_id, top_category)",
+ Example: " human-goat-pp-cli categories",
+ Annotations: map[string]string{"pp:endpoint": "categories.list", "pp:method": "GET", "pp:path": "/api/v3/web-client/metro_task_template.json", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/api/v3/web-client/metro_task_template.json"
+ params := map[string]string{}
+ data, prov, err := resolveReadWithStrategyAndResponsePath(cmd.Context(), c, flags, "auto", "categories", true, path, params, nil, "initial_task_templates", cmd.ErrOrStderr())
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_endpoint.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ if json.Unmarshal(data, &countItems) != nil {
+ // Single object, not an array
+ countItems = []json.RawMessage{data}
+ }
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope. --select wins over
+ // --compact when both are set; --compact only runs when no explicit
+ // fields were requested. Explicit format flags (--csv, --quiet, --plain)
+ // opt out of the auto-JSON path so piped consumers that asked for a
+ // non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlagsMeta(cmd.OutOrStdout(), data, flags, map[string]any{"source": "live"})
+ },
+ }
+
+ // Wire sibling endpoints and sub-resources as subcommands
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/promoted_invoices.go b/library/productivity/human-goat/internal/cli/promoted_invoices.go
new file mode 100644
index 0000000000..819cc06eb6
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/promoted_invoices.go
@@ -0,0 +1,84 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newInvoicesPromotedCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "invoices",
+ Short: "Whether the account has submitted invoices (payment-history presence flag)",
+ Long: "Whether the account has submitted invoices (payment-history presence flag)",
+ Example: " human-goat-pp-cli invoices",
+ Annotations: map[string]string{"pp:endpoint": "invoices.has_submitted", "pp:method": "GET", "pp:path": "/api/v3/decline_invoice/has_submitted_invoices", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/api/v3/decline_invoice/has_submitted_invoices"
+ params := map[string]string{}
+ data, prov, err := resolveReadWithStrategyAndResponsePath(cmd.Context(), c, flags, "auto", "invoices", false, path, params, nil, "", cmd.ErrOrStderr())
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_endpoint.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ if json.Unmarshal(data, &countItems) != nil {
+ // Single object, not an array
+ countItems = []json.RawMessage{data}
+ }
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope. --select wins over
+ // --compact when both are set; --compact only runs when no explicit
+ // fields were requested. Explicit format flags (--csv, --quiet, --plain)
+ // opt out of the auto-JSON path so piped consumers that asked for a
+ // non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlagsMeta(cmd.OutOrStdout(), data, flags, map[string]any{"source": "live"})
+ },
+ }
+
+ // Wire sibling endpoints and sub-resources as subcommands
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/reply.go b/library/productivity/human-goat/internal/cli/reply.go
new file mode 100644
index 0000000000..ba1529ad36
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/reply.go
@@ -0,0 +1,86 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+// pp:data-source live
+
+package cli
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/magic"
+)
+
+type magicReplyOutput struct {
+ RequestID string `json:"request_id"`
+ Replied bool `json:"replied"`
+ Response map[string]any `json:"response"`
+}
+
+func newMagicReplyCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "reply ",
+ Short: "Reply to a Magic request conversation",
+ Example: ` human-goat-pp-cli reply req_123 "Please ask whether they can do Friday morning."
+ human-goat-pp-cli reply req_123 "Yes, approve that option."`,
+ Annotations: map[string]string{"pp:no-error-path-probe": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && cmd.Flags().NFlag() == 0 {
+ return cmd.Help()
+ }
+ if len(args) > 2 {
+ return usageErr(fmt.Errorf("reply accepts exactly two positional arguments: "))
+ }
+ requestID := ""
+ content := ""
+ if len(args) > 0 {
+ requestID = strings.TrimSpace(args[0])
+ }
+ if len(args) > 1 {
+ content = strings.TrimSpace(args[1])
+ }
+ if dryRunOK(flags) || cliutil.IsVerifyEnv() || cliutil.IsDogfoodEnv() {
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{"dry_run": true, "would_reply_to": requestID}, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "would reply to %s\n", requestID)
+ return nil
+ }
+ if requestID == "" {
+ return usageErr(fmt.Errorf("request id is required"))
+ }
+ if content == "" {
+ return usageErr(fmt.Errorf("message is required"))
+ }
+
+ ctx, cancel := boundCtx(cmd.Context(), flags)
+ defer cancel()
+
+ client, err := magic.NewClient()
+ if err != nil {
+ return fmt.Errorf("initialize Magic client: %w", err)
+ }
+ response, err := client.Reply(ctx, requestID, content)
+ if err != nil {
+ return fmt.Errorf("reply to Magic request %s: %w", requestID, err)
+ }
+ if response == nil {
+ response = make(map[string]any)
+ }
+ out := magicReplyOutput{
+ RequestID: requestID,
+ Replied: true,
+ Response: response,
+ }
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "replied to %s\n", requestID)
+ return nil
+ },
+ }
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/root.go b/library/productivity/human-goat/internal/cli/root.go
new file mode 100644
index 0000000000..d8db1ba8b2
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/root.go
@@ -0,0 +1,584 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/config"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+type rootFlags struct {
+ asJSON bool
+ compact bool
+ csv bool
+ plain bool
+ quiet bool
+ dryRun bool
+ noCache bool
+ noInput bool
+ yes bool
+ agent bool
+ // noLearn disables both teach (write) and recall (read) for this
+ // invocation. Mirrors the HUMAN_GOAT_NO_LEARN env var.
+ noLearn bool
+ selectFields string
+ configPath string
+ homePath string
+ profileName string
+ deliverSpec string
+ timeout time.Duration
+ rateLimit float64
+ maxAge time.Duration
+ dataSource string
+ freshnessMeta any
+
+ // deliverBuf captures command output when --deliver is set to a
+ // non-stdout sink. Flushed to the sink after Execute returns.
+ deliverBuf *bytes.Buffer
+ deliverSink DeliverSink
+}
+
+// RootCmd returns the Cobra command tree without executing it. The MCP server
+// uses this to mirror every user-facing command as an agent tool.
+func RootCmd() *cobra.Command {
+ var flags rootFlags
+ return newRootCmd(&flags)
+}
+
+// Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.
+// The named return feeds the deferred journal write: one site after
+// ExecuteC returns covers every outcome, including RunE errors (a
+// Cobra PostRun hook would be skipped on RunE error, so none is used).
+func Execute() (retErr error) {
+ var flags rootFlags
+ rootCmd := newRootCmd(&flags)
+
+ executedCmd, err := rootCmd.ExecuteC()
+ var journalFailedFlag, journalSuggestedFlag string
+ defer func() {
+ journalInvocation(&flags, rootCmd, executedCmd, retErr, journalFailedFlag, journalSuggestedFlag)
+ // Derivation runs after the journal write so the entry this
+ // invocation just recorded is visible to the tail scan.
+ deriveFlagCorrections(&flags, rootCmd, executedCmd)
+ }()
+ if errors.Is(err, pflag.ErrHelp) {
+ return nil
+ }
+ if err != nil && strings.Contains(err.Error(), "unknown flag") {
+ msg := err.Error()
+ // Extract the flag name from the error message (e.g., "unknown flag: --foob")
+ if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
+ flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
+ // Parse-failure journal enrichment: PersistentPreRunE never
+ // runs for flag-parse failures, so this is the only place the
+ // failed flag (and its did-you-mean suggestion, when one
+ // exists) is observable. The deferred journal write picks
+ // these up.
+ journalFailedFlag = flagStr
+ if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
+ // Cobra already printed `Error: unknown flag: --foob` before
+ // returning; the wrap below attaches the hint to err.Error()
+ // for downstream consumers and exit-code classification, but
+ // would never reach stderr now that main.go no longer prints
+ // err. Emit the hint explicitly so the suggestion still
+ // shows up under Cobra's error line.
+ fmt.Fprintf(os.Stderr, "hint: did you mean --%s?\n", suggestion)
+ err = fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
+ journalSuggestedFlag = "--" + suggestion
+ }
+ }
+ }
+ if err == nil && flags.deliverBuf != nil {
+ if derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {
+ fmt.Fprintf(os.Stderr, "warning: deliver to %s:%s failed: %v\n", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)
+ return derr
+ }
+ }
+ if err != nil && isCobraUsageError(err) {
+ // Cobra/pflag pre-RunE errors (unknown flag, unknown command,
+ // missing required, etc.) never flow through usageErr() because
+ // they originate inside rootCmd.Execute() before any user RunE
+ // runs. Without this wrap, ExitCode() falls through to the
+ // default and emits 1 — clobbering the conventional code-2 for
+ // usage errors that the helpers.go contract already promises.
+ return usageErr(err)
+ }
+ return err
+}
+
+// isCobraUsageError reports whether err matches one of Cobra/pflag's
+// pre-RunE usage-error shapes. Detection is by message prefix to match
+// the same approach the unknown-flag hint path uses above; neither
+// Cobra nor pflag exports typed sentinels for these.
+//
+// Patterns are anchored to the literal punctuation Cobra and pflag
+// emit so an application's own RunE error that happens to contain the
+// substring "required flag" or "invalid argument" doesn't get
+// misclassified as a usage error.
+//
+// Patterns covered (Cobra v1.x + pflag v1.x as of 2026-05):
+// - "unknown flag: --foo" (pflag)
+// - "unknown shorthand flag: 'x' in -x" (pflag)
+// - "unknown command \"foo\" for ..." (Cobra)
+// - "required flag \"foo\" not set" (Cobra, single missing)
+// - "required flag(s) \"foo\" not set" (Cobra, multiple missing)
+// - "flag needs an argument: --foo" (pflag, missing value)
+// - "invalid argument \"x\" for \"--y\" flag: ..." (pflag, parse failure)
+//
+// Cobra emits the singular form ("required flag") when exactly one
+// MarkFlagRequired flag is missing, and the plural form ("required
+// flag(s)") only when multiple are missing on the same command. Both
+// shapes must be anchored to avoid matching app-level errors that
+// happen to mention "required flag" as prose; the trailing space + quote
+// (`required flag "`) is the literal punctuation cobra emits.
+//
+// Returns false for nil err.
+func isCobraUsageError(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ return strings.HasPrefix(msg, "unknown flag") ||
+ strings.HasPrefix(msg, "unknown shorthand flag") ||
+ strings.HasPrefix(msg, "unknown command") ||
+ strings.HasPrefix(msg, `required flag "`) ||
+ strings.HasPrefix(msg, `required flag(s) "`) ||
+ strings.HasPrefix(msg, "flag needs an argument:") ||
+ strings.HasPrefix(msg, `invalid argument "`)
+}
+
+func newRootCmd(flags *rootFlags) *cobra.Command {
+ rootCmd := &cobra.Command{
+ Use: "human-goat-pp-cli",
+ Short: `Hire real humans from the terminal — autonomous TaskRabbit checkout with all-in pricing and a verified undo, plus Magic remote errands, in one agent-native binary.`,
+ Long: `Hire real humans from the terminal — autonomous TaskRabbit checkout with all-in pricing and a verified undo, plus Magic remote errands, in one agent-native binary.
+
+Highlights (not in the official API docs):
+ • hire Say the job and the date; goat searches, ranks by review quality and honest all-in price, and checks out against the card on file with no prompt.
+ • cancel Cancels a booking and confirms it landed by re-reading status, reporting whether it was inside the free window and any fee.
+ • hire Refuses to check out when the computed all-in total exceeds a configurable ceiling, printing the total and the cap.
+ • best Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate, honoring the CA/MA service-fee-only rule.
+ • dispatch Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override.
+ • spend SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month, using true effective all-in $/hr for TaskRabbit.
+ • watch Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens.
+ • status One list of every in-flight task across TaskRabbit bookings and Magic requests, joined on the common task model and sorted by state.
+
+Agent mode: add --agent to any command for JSON output + non-interactive mode.
+Health check: run 'human-goat-pp-cli doctor' to verify auth and connectivity.
+See README.md or the bundled SKILL.md for recipes.`,
+ SilenceUsage: true,
+ Version: version,
+ }
+ rootCmd.SetVersionTemplate("human-goat-pp-cli {{ .Version }}\n")
+
+ rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "Output as JSON")
+ rootCmd.PersistentFlags().BoolVar(&flags.compact, "compact", false, "Return only key fields (id, name, status, timestamps) for minimal token usage")
+ rootCmd.PersistentFlags().BoolVar(&flags.csv, "csv", false, "Output as CSV (table and array responses)")
+ rootCmd.PersistentFlags().BoolVar(&flags.plain, "plain", false, "Output as plain tab-separated text")
+ rootCmd.PersistentFlags().BoolVar(&flags.quiet, "quiet", false, "Bare output, one value per line")
+ rootCmd.PersistentFlags().StringVar(&flags.configPath, "config", "", "Config file path")
+ rootCmd.PersistentFlags().StringVar(&flags.homePath, "home", "", "Root directory for config, data, state, and cache files")
+ rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 60*time.Second, "Request timeout")
+ rootCmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Show request without sending")
+ rootCmd.PersistentFlags().BoolVar(&flags.noCache, "no-cache", false, "Bypass response cache")
+ rootCmd.PersistentFlags().BoolVar(&flags.noInput, "no-input", false, "Disable all interactive prompts (for CI/agents)")
+ rootCmd.PersistentFlags().StringVar(&flags.selectFields, "select", "", "Comma-separated fields to include in output (e.g. --select id,name,status)")
+ rootCmd.PersistentFlags().BoolVar(&flags.yes, "yes", false, "Skip confirmation prompts (for agents and scripts)")
+ rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output")
+ rootCmd.PersistentFlags().BoolVar(&humanFriendly, "human-friendly", false, "Enable colored output and rich formatting")
+ rootCmd.PersistentFlags().BoolVar(&flags.agent, "agent", false, "Set all agent-friendly defaults (--json --compact --no-input --no-color --yes)")
+ rootCmd.PersistentFlags().BoolVar(&flags.noLearn, "no-learn", false, "Disable the teach/recall learning loop for this invocation")
+ rootCmd.PersistentFlags().StringVar(&flags.dataSource, "data-source", "auto", "Data source for read commands: auto (live with local fallback), live (API only), local (synced data only)")
+ rootCmd.PersistentFlags().DurationVar(&flags.maxAge, "max-age", 30*time.Minute, "Maximum acceptable age of local-store data before a stderr hint suggests sync; 0 disables")
+ rootCmd.PersistentFlags().StringVar(&flags.profileName, "profile", "", "Apply values from a saved profile (see 'human-goat-pp-cli profile list')")
+ rootCmd.PersistentFlags().StringVar(&flags.deliverSpec, "deliver", "", "Route output to a sink: stdout (default), file:, webhook:")
+ rootCmd.PersistentFlags().Float64Var(&flags.rateLimit, "rate-limit", 0, "Max requests per second (0 to disable)")
+
+ rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
+ if _, err := cliutil.SetHomeOverride(flags.homePath); err != nil {
+ return err
+ }
+ if flags.deliverSpec != "" {
+ sink, err := ParseDeliverSink(flags.deliverSpec)
+ if err != nil {
+ return err
+ }
+ flags.deliverSink = sink
+ if sink.Scheme != "stdout" && sink.Scheme != "" {
+ flags.deliverBuf = &bytes.Buffer{}
+ cmd.SetOut(io.MultiWriter(os.Stdout, flags.deliverBuf))
+ }
+ }
+ if flags.profileName != "" {
+ profile, err := GetProfile(flags.profileName)
+ if err != nil {
+ return err
+ }
+ if profile == nil {
+ available := ListProfileNames()
+ if len(available) == 0 {
+ return fmt.Errorf("profile %q not found (no profiles saved yet; run '%s profile save -- ')", flags.profileName, cmd.Root().Name())
+ }
+ return fmt.Errorf("profile %q not found; available: %s", flags.profileName, strings.Join(available, ", "))
+ }
+ if err := ApplyProfileToFlags(cmd, profile); err != nil {
+ return err
+ }
+ }
+ if flags.agent {
+ if !cmd.Flags().Changed("json") {
+ flags.asJSON = true
+ }
+ if !cmd.Flags().Changed("compact") {
+ flags.compact = true
+ }
+ if !cmd.Flags().Changed("no-input") {
+ flags.noInput = true
+ }
+ if !cmd.Flags().Changed("yes") {
+ flags.yes = true
+ }
+ if !cmd.Flags().Changed("no-color") {
+ noColor = true
+ }
+ }
+ switch flags.dataSource {
+ case "auto", "live", "local":
+ // valid
+ default:
+ return fmt.Errorf("invalid --data-source value %q: must be auto, live, or local", flags.dataSource)
+ }
+ // Seed entity_lookups from spec.Learn.EntityLookupSeeds once per
+ // process. Skipped for framework commands that should never
+ // touch the local store (auth, doctor, help, etc.) and for
+ // --no-learn invocations so deterministic agent flows don't
+ // race a background seed.
+ if !noLearnActive(flags) && !shouldSkipLearnHook(cmd.CommandPath()) {
+ runLearnInitOnce(cmd.Context())
+ runPlaybookInitOnce(cmd.Context())
+ }
+ return nil
+ }
+ rootCmd.AddCommand(newSystemCmd(flags))
+ rootCmd.AddCommand(newTaskersCmd(flags))
+ rootCmd.AddCommand(newTasksCmd(flags))
+ rootCmd.AddCommand(newDoctorCmd(flags))
+ rootCmd.AddCommand(newAuthCmd(flags))
+ rootCmd.AddCommand(newAgentContextCmd(rootCmd))
+ rootCmd.AddCommand(newProfileCmd(flags))
+ rootCmd.AddCommand(newFeedbackCmd(flags))
+ rootCmd.AddCommand(newWhichCmd(flags))
+ rootCmd.AddCommand(newSearchCmd(flags))
+ rootCmd.AddCommand(newSyncCmd(flags))
+ rootCmd.AddCommand(newWorkflowCmd(flags))
+ rootCmd.AddCommand(newNovelBestCmd(flags))
+ rootCmd.AddCommand(newNovelCancelCmd(flags))
+ rootCmd.AddCommand(newNovelDispatchCmd(flags))
+ rootCmd.AddCommand(newNovelHireCmd(flags))
+ rootCmd.AddCommand(newNovelSpendCmd(flags))
+ rootCmd.AddCommand(newNovelWatchCmd(flags))
+ rootCmd.AddCommand(newNovelStatusCmd(flags))
+ rootCmd.AddCommand(newMagicSendCmd(flags))
+ rootCmd.AddCommand(newMagicCallCmd(flags))
+ rootCmd.AddCommand(newMagicReplyCmd(flags))
+ rootCmd.AddCommand(newMagicTrackCmd(flags))
+ rootCmd.AddCommand(newAPICmd(flags))
+ rootCmd.AddCommand(newAccountPromotedCmd(flags))
+ rootCmd.AddCommand(newCategoriesPromotedCmd(flags))
+ rootCmd.AddCommand(newInvoicesPromotedCmd(flags))
+ rootCmd.AddCommand(newVersionCmd())
+ // Self-learning loop commands. newLearnConfig (defined in
+ // learn_init.go) reads spec.Learn.TickerPatterns + Stopwords and
+ // returns a configured *entities.Config every call site shares;
+ // initLearn seeds entity_lookups from spec.Learn.EntityLookupSeeds
+ // once per process via the PersistentPreRunE hook above.
+ learnCfg := newLearnConfig()
+ rootCmd.AddCommand(newTeachCmd(flags, learnCfg))
+ rootCmd.AddCommand(newRecallCmd(flags, learnCfg))
+ rootCmd.AddCommand(newLearningsCmd(flags, learnCfg))
+ rootCmd.AddCommand(newTeachPatternCmd(flags))
+ rootCmd.AddCommand(newTeachLookupCmd(flags))
+ rootCmd.AddCommand(newTeachPlaybookCmd(flags, learnCfg))
+ rootCmd.AddCommand(newPlaybookCmd(flags, learnCfg))
+
+ return rootCmd
+}
+
+// learnHookSkipList enumerates framework command path segments that any
+// future PersistentPreRunE recall hook must NOT trigger on. Today the
+// teach/recall path is invoked explicitly by the agent, so there is
+// no consumer of this list at runtime; the skip-list ships in v1 as
+// forward-looking framework so a later auto-recall hook (modeled on
+// the granola autorefresh shape) can consult it without re-deriving
+// the set in every PR.
+//
+// Names match any segment of Cobra's CommandPath. Aliases (e.g. "sync-api")
+// are matched as-is.
+var learnHookSkipList = map[string]struct{}{
+ "auth": {},
+ "doctor": {},
+ "help": {},
+ "sync": {},
+ "profile": {},
+ "feedback": {},
+ "which": {},
+ "agent-context": {},
+ "completion": {},
+ "version": {},
+}
+
+// shouldSkipLearnHook reports whether a recall pre-run hook should
+// short-circuit for commandPath.
+func shouldSkipLearnHook(commandPath string) bool {
+ for _, segment := range strings.Fields(commandPath) {
+ if _, skip := learnHookSkipList[segment]; skip {
+ return true
+ }
+ }
+ return false
+}
+
+// journalInvocation records the invocation in the learn journal from
+// Execute()'s single post-ExecuteC site. Fail-open by construction:
+// learn.JournalInvocation never returns an error and warns to stderr
+// at most once, so journaling can never fail or slow the command.
+//
+// Known accepted gap: for a flag-parse failure PersistentPreRunE never
+// runs, so a --home/--profile relocation was never applied and the
+// parse-failure entry lands in the default state dir rather than the
+// relocated one. Successful runs journal post-run, after the override
+// took effect, so their entries land in the relocated dir.
+func journalInvocation(flags *rootFlags, rootCmd, executed *cobra.Command, err error, failedFlag, suggestedFlag string) {
+ // The master --no-learn switch kills journaling too. On the
+ // parse-failure path the flag was never parsed into rootFlags, so
+ // the raw args are consulted as well.
+ if noLearnActive(flags) || argsDisableLearn(os.Args[1:]) {
+ return
+ }
+ exitCode := 0
+ errorClass := ""
+ if err != nil {
+ exitCode = ExitCode(err)
+ if isCobraUsageError(err) {
+ errorClass = "usage"
+ } else {
+ errorClass = "runtime"
+ }
+ }
+ // Resolve bool-ness of flags against the executed command's
+ // registry so the argv shape doesn't misread "--json list" as a
+ // valued flag. On parse failures executed may be nil or root.
+ target := executed
+ if target == nil {
+ target = rootCmd
+ }
+ isBoolFlag := func(name string) bool {
+ f := target.Flags().Lookup(name)
+ if f == nil {
+ f = target.InheritedFlags().Lookup(name)
+ }
+ if f == nil && len(name) == 1 {
+ f = target.Flags().ShorthandLookup(name)
+ }
+ return f != nil && f.Value.Type() == "bool"
+ }
+ learn.JournalInvocation(learn.JournalEntry{
+ Cmd: journalVerbChain(rootCmd, executed),
+ ArgvShape: learn.JournalArgvShape(os.Args[1:], isBoolFlag),
+ ExitCode: exitCode,
+ ErrorClass: errorClass,
+ FailedFlag: failedFlag,
+ SuggestedFlag: suggestedFlag,
+ })
+}
+
+// journalVerbChain resolves the subcommand verb chain for the journal
+// entry. When ExecuteC resolved a command, its CommandPath is
+// authoritative. On the parse-failure path where no command resolved,
+// the chain is derived by matching os.Args tokens against registered
+// command names only — an unmatched token may be a positional value
+// and is never recorded (matching stops there, conservatively).
+func journalVerbChain(rootCmd, executed *cobra.Command) []string {
+ if executed != nil && executed != rootCmd {
+ parts := strings.Fields(executed.CommandPath())
+ if len(parts) > 1 {
+ return parts[1:]
+ }
+ }
+ var chain []string
+ current := rootCmd
+ for _, tok := range os.Args[1:] {
+ if strings.HasPrefix(tok, "-") {
+ // Flag token; a separated flag value that follows is an
+ // unmatched token and stops the walk below.
+ continue
+ }
+ next := findSubcommand(current, tok)
+ if next == nil {
+ break
+ }
+ // Record the canonical registered name (resolving aliases),
+ // never the raw token.
+ chain = append(chain, next.Name())
+ current = next
+ }
+ return chain
+}
+
+func findSubcommand(cmd *cobra.Command, name string) *cobra.Command {
+ for _, c := range cmd.Commands() {
+ if c.Name() == name || c.HasAlias(name) {
+ return c
+ }
+ }
+ return nil
+}
+
+// argsDisableLearn reports whether the raw args carry the master
+// --no-learn switch. Needed on the parse-failure journal path where
+// pflag never populated rootFlags; an explicit --no-learn=false does
+// not disable.
+func argsDisableLearn(args []string) bool {
+ for _, tok := range args {
+ if tok == "--no-learn" {
+ return true
+ }
+ if v, ok := strings.CutPrefix(tok, "--no-learn="); ok {
+ switch strings.ToLower(strings.TrimSpace(v)) {
+ case "false", "0", "no":
+ default:
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// learnFamilyCommands are the commands whose own invocations must
+// never trigger a derivation pass — deriving off the learn surface's
+// own journal traffic would compound into derive-on-derive noise.
+// Their entries still land in the journal; only the pass is skipped.
+var learnFamilyCommands = map[string]struct{}{
+ "teach": {},
+ "recall": {},
+ "learnings": {},
+ "playbook": {},
+ "teach-pattern": {},
+ "teach-lookup": {},
+ "teach-playbook": {},
+}
+
+// deriveFlagCorrections runs the post-run flag-correction derivation
+// pass from Execute()'s single post-ExecuteC site, right after the
+// invocation's own journal entry lands. Best-effort and silent: it is
+// skipped under every switch the journal honors, and any failure is
+// swallowed — derivation may never fail, slow, or add output to the
+// command that triggered it.
+func deriveFlagCorrections(flags *rootFlags, rootCmd, executed *cobra.Command) {
+ if noLearnActive(flags) || argsDisableLearn(os.Args[1:]) || learn.JournalCaptureDisabled() {
+ return
+ }
+ chain := journalVerbChain(rootCmd, executed)
+ if len(chain) > 0 {
+ if _, isLearn := learnFamilyCommands[chain[0]]; isLearn {
+ return
+ }
+ }
+ flagExists := func(name string) bool {
+ return commandTreeHasFlag(rootCmd, strings.TrimLeft(name, "-"))
+ }
+ // The opener is lazy: the pass touches SQLite only when it paired
+ // a correction, so framework-only invocations never create the
+ // learn database from this path.
+ openStore := func() (learn.CandidateStore, error) {
+ return store.Open(learnDBPath(""))
+ }
+ _ = learn.DeriveFlagCorrections(openStore, flagExists)
+}
+
+// commandTreeHasFlag reports whether any command in the tree registers
+// a flag with the given name (long name or single-letter shorthand).
+// The derivation pairing rule only heals flags that resolve nowhere —
+// a name that exists on any command, even a sibling of the failed one,
+// is a usage error rather than an alias candidate.
+func commandTreeHasFlag(cmd *cobra.Command, name string) bool {
+ if name == "" {
+ return false
+ }
+ if cmd.Flags().Lookup(name) != nil || cmd.PersistentFlags().Lookup(name) != nil {
+ return true
+ }
+ if len(name) == 1 && cmd.Flags().ShorthandLookup(name) != nil {
+ return true
+ }
+ for _, child := range cmd.Commands() {
+ if commandTreeHasFlag(child, name) {
+ return true
+ }
+ }
+ return false
+}
+
+func ExitCode(err error) int {
+ var codeErr *cliError
+ if As(err, &codeErr) {
+ return codeErr.code
+ }
+ return 1
+}
+
+func (f *rootFlags) newClient() (*client.Client, error) {
+ cfg, err := config.Load(f.configPath)
+ if err != nil {
+ return nil, configErr(err)
+ }
+ c := client.New(cfg, f.timeout, f.rateLimit)
+ c.DryRun = f.dryRun
+ c.NoCache = f.noCache
+ return c, nil
+}
+
+func (f *rootFlags) printJSON(w *cobra.Command, v any) error {
+ return printJSONFiltered(w.OutOrStdout(), v, f)
+}
+
+func (f *rootFlags) printTable(w *cobra.Command, headers []string, rows [][]string) error {
+ if f.asJSON {
+ return fmt.Errorf("use printJSON for JSON output")
+ }
+ tw := tabwriter.NewWriter(w.OutOrStdout(), 2, 4, 2, ' ', 0)
+ header := ""
+ for i, h := range headers {
+ if i > 0 {
+ header += "\t"
+ }
+ header += h
+ }
+ fmt.Fprintln(tw, header)
+ for _, row := range rows {
+ line := ""
+ for i, cell := range row {
+ if i > 0 {
+ line += "\t"
+ }
+ line += cell
+ }
+ fmt.Fprintln(tw, line)
+ }
+ return tw.Flush()
+}
diff --git a/library/productivity/human-goat/internal/cli/root_test.go b/library/productivity/human-goat/internal/cli/root_test.go
new file mode 100644
index 0000000000..216d65d77a
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/root_test.go
@@ -0,0 +1,206 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "testing"
+)
+
+// TestIsCobraUsageError covers the six pre-RunE error shapes Cobra and
+// pflag can produce before any user RunE runs. Each must be detected so
+// the caller can wrap in usageErr() and yield exit code 2.
+func TestIsCobraUsageError(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ errIn error
+ want bool
+ }{
+ {"nil", nil, false},
+ {"unknown flag", errors.New("unknown flag: --foob"), true},
+ {"unknown shorthand flag", errors.New("unknown shorthand flag: 'x' in -x"), true},
+ {"unknown command", errors.New("unknown command \"foo\" for \"cli\""), true},
+ {"required flag (singular)", errors.New("required flag \"query\" not set"), true},
+ {"required flag(s) (plural)", errors.New("required flag(s) \"query\", \"vault\" not set"), true},
+ {"flag needs argument", errors.New("flag needs an argument: --query"), true},
+ {"invalid argument", errors.New("invalid argument \"abc\" for \"--limit\" flag: strconv.ParseInt: parsing \"abc\": invalid syntax"), true},
+ // Non-usage errors must NOT be flagged — they should retain their
+ // original (or wrapped *cliError) exit code.
+ {"API error pass-through", errors.New("API returned 500"), false},
+ {"network error pass-through", errors.New("dial tcp: connection refused"), false},
+ // Pattern-tightness regressions: application RunE errors that
+ // happen to mention "required flag" / "flag needs an argument" /
+ // "invalid argument" as prose must NOT classify as usage errors.
+ // The anchored prefixes guard against this; if the patterns are
+ // ever loosened back to Contains() these tests catch the drift.
+ {"app msg containing 'required flag' as prose", errors.New("the required flag config is missing from the manifest"), false},
+ {"app msg containing 'flag needs an argument' as prose", errors.New("this flag needs an argument upstream before retry"), false},
+ {"app msg containing 'invalid argument' as prose", errors.New("invalid argument provided to handler at /foo"), false},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ if got := isCobraUsageError(tc.errIn); got != tc.want {
+ t.Errorf("isCobraUsageError(%v) = %v, want %v", tc.errIn, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestIsCobraUsageError_SurvivesHintRewrap covers the hint-suggestion
+// path in Execute(): after rewrapping an "unknown flag" error with
+// fmt.Errorf("%w\nhint: ...", err, ...), the rewrapped error must still
+// classify as a usage error so the exit code stays at 2 rather than
+// silently falling through to 1.
+func TestIsCobraUsageError_SurvivesHintRewrap(t *testing.T) {
+ t.Parallel()
+ original := errors.New("unknown flag: --foob")
+ wrapped := fmt.Errorf("%w\nhint: did you mean --foo?", original)
+ if !isCobraUsageError(wrapped) {
+ t.Errorf("hint-rewrapped unknown-flag error must still classify as usage error, got: %v", wrapped)
+ }
+}
+
+// TestExitCode_UsageError_WrappedAsCode2 covers the end-to-end contract:
+// after Execute() wraps a Cobra usage error in usageErr(), ExitCode()
+// must return 2, not 1. This is the user-visible promise — a bad flag
+// exits 2 (POSIX usage convention), not the generic-error-1 fallback.
+func TestExitCode_UsageError_WrappedAsCode2(t *testing.T) {
+ t.Parallel()
+ wrapped := usageErr(errors.New("unknown flag: --foob"))
+ if got := ExitCode(wrapped); got != 2 {
+ t.Errorf("ExitCode(usageErr(...)) = %d, want 2 (POSIX usage convention)", got)
+ }
+}
+
+// TestFilterFields covers --select projection against the four payload
+// shapes printed CLIs see in practice: bare arrays, direct objects,
+// list envelopes (Stripe/GitHub/Notion-style wrapper + array), and
+// flat objects. The envelope cases guard against a regression where
+// wrapper-key + array responses returned `{}` because the selector
+// heads matched the inner record fields, not the wrapper key.
+func TestFilterFields(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ input string
+ fields string
+ want string
+ }{
+ {
+ name: "bare array element-wise",
+ input: `[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]`,
+ fields: "id,name",
+ want: `[{"id":"a","name":"x"},{"id":"b","name":"z"}]`,
+ },
+ {
+ name: "direct object top-level match",
+ input: `{"id":"a","name":"b","other":"c"}`,
+ fields: "id,name",
+ want: `{"id":"a","name":"b"}`,
+ },
+ {
+ name: "envelope single array sibling (orgo {projects:[...]})",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "id,name",
+ want: `{"projects":[{"id":"a","name":"x"}]}`,
+ },
+ {
+ name: "envelope with metadata sibling (github {total_count,items:[...]})",
+ input: `{"total_count":2,"items":[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]}`,
+ fields: "id,name",
+ want: `{"items":[{"id":"a","name":"x"},{"id":"b","name":"z"}],"total_count":2}`,
+ },
+ {
+ name: "envelope with metadata sibling (stripe {object,data:[...]})",
+ input: `{"object":"list","data":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "id,name",
+ want: `{"data":[{"id":"a","name":"x"}],"object":"list"}`,
+ },
+ {
+ name: "selector matches envelope key (no descent into array)",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "projects",
+ want: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ },
+ {
+ name: "dotted-path through envelope key still descends",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "projects.id",
+ want: `{"projects":[{"id":"a"}]}`,
+ },
+ {
+ name: "flat object no match returns empty (no array fallback)",
+ input: `{"a":1,"b":2}`,
+ fields: "c",
+ want: `{}`,
+ },
+ {
+ // Null pagination cursors are common envelope metadata.
+ // json.Unmarshal accepts JSON null into []json.RawMessage as
+ // a nil slice without error, so the array check must reject
+ // nil explicitly or null siblings would be coerced to `[]`.
+ name: "envelope preserves null sibling verbatim",
+ input: `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+ fields: "id,name",
+ want: `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+ },
+ {
+ // Without a real array sibling the envelope fallback does not
+ // fire, so a flat object whose only "extra" key is null still
+ // returns {} for a non-matching selector.
+ name: "flat object with null sibling no match returns empty",
+ input: `{"a":1,"b":null}`,
+ fields: "c",
+ want: `{}`,
+ },
+ {
+ // Multiple array siblings at the same level each receive the
+ // selector independently. Documents that the projection fans
+ // out across every array, not just the first one found.
+ name: "envelope with two array siblings filters both",
+ input: `{"events":[{"id":"e1","other":"x"}],"speakers":[{"id":"s1","other":"y"}]}`,
+ fields: "id",
+ want: `{"events":[{"id":"e1"}],"speakers":[{"id":"s1"}]}`,
+ },
+ {
+ // Envelope fallback is intentionally one level deep. A nested
+ // object envelope like {"data":{"items":[...]}} surfaces no
+ // array at the outer level, so the fallback does not fire and
+ // the result is the empty-object that flat-no-match would
+ // produce. Pins the boundary so a future deeper-walk change
+ // is an explicit decision, not an accident.
+ name: "nested object envelope returns empty (one-level only)",
+ input: `{"data":{"items":[{"id":"a","other":"y"}]}}`,
+ fields: "id",
+ want: `{}`,
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := filterFields(json.RawMessage(tc.input), tc.fields)
+ // Normalize both sides through json.Unmarshal+Marshal so
+ // map-iteration order does not produce false negatives.
+ var gotV, wantV interface{}
+ if err := json.Unmarshal(got, &gotV); err != nil {
+ t.Fatalf("got is invalid json: %v (raw=%s)", err, string(got))
+ }
+ if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+ t.Fatalf("want is invalid json: %v (raw=%s)", err, tc.want)
+ }
+ gotBytes, _ := json.Marshal(gotV)
+ wantBytes, _ := json.Marshal(wantV)
+ if string(gotBytes) != string(wantBytes) {
+ t.Errorf("filterFields(%q, %q) = %s, want %s",
+ tc.input, tc.fields, string(gotBytes), string(wantBytes))
+ }
+ })
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/search.go b/library/productivity/human-goat/internal/cli/search.go
new file mode 100644
index 0000000000..3662326d31
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/search.go
@@ -0,0 +1,231 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+ "github.com/spf13/cobra"
+)
+
+// isNilOrEmpty checks whether a JSON search hit is only an empty shell.
+func isNilOrEmpty(raw json.RawMessage) bool {
+ var obj map[string]interface{}
+ if err := json.Unmarshal(raw, &obj); err != nil {
+ return true
+ }
+ if _, ok := obj["score"]; ok {
+ return false
+ }
+ return !hasAnyNonEmptySearchValue(obj)
+}
+
+func hasAnyNonEmptySearchValue(v any) bool {
+ switch typed := v.(type) {
+ case nil:
+ return false
+ case string:
+ return strings.TrimSpace(typed) != ""
+ case bool, float64:
+ return true
+ case []any:
+ for _, item := range typed {
+ if hasAnyNonEmptySearchValue(item) {
+ return true
+ }
+ }
+ case map[string]any:
+ for _, item := range typed {
+ if hasAnyNonEmptySearchValue(item) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// extractSearchResults unwraps API search responses by checking common envelope paths.
+func extractSearchResults(data json.RawMessage, responsePaths ...string) []json.RawMessage {
+ // Try direct array first
+ var items []json.RawMessage
+ if json.Unmarshal(data, &items) == nil {
+ return items
+ }
+ for _, responsePath := range responsePaths {
+ if pathData, ok := responsePayloadAtPath(data, responsePath); ok {
+ if json.Unmarshal(pathData, &items) == nil {
+ return items
+ }
+ }
+ }
+ // Try common wrapper paths: data, results, items
+ var wrapped map[string]json.RawMessage
+ if json.Unmarshal(data, &wrapped) == nil {
+ for _, key := range []string{"data", "results", "items", "records", "entries"} {
+ if inner, ok := wrapped[key]; ok {
+ if json.Unmarshal(inner, &items) == nil {
+ return items
+ }
+ }
+ }
+ }
+ // Return as single-item array
+ return []json.RawMessage{data}
+}
+
+func newSearchCmd(flags *rootFlags) *cobra.Command {
+ var resourceType string
+ var limit int
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "search ",
+ Short: "Search locally synced data",
+ Long: `Search locally synced data using FTS5 full-text search.
+
+This API has no dedicated search endpoint, so live mode is unavailable.
+Run sync first to populate the local search index.`,
+ Example: ` # Search locally synced data
+ human-goat-pp-cli search "status"
+
+ # Force local search explicitly
+ human-goat-pp-cli search "status" --data-source local
+ # Search a specific resource type locally
+ human-goat-pp-cli search "status" --type account --data-source local
+ # JSON output for piping
+ human-goat-pp-cli search "status" --json --limit 20`,
+ Annotations: map[string]string{"mcp:hidden": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return cmd.Help()
+ }
+ query := args[0]
+ // This API has no search endpoint.
+ if flags.dataSource == "live" {
+ return fmt.Errorf("this API has no search endpoint. Use --data-source local or --data-source auto to search locally synced data")
+ }
+ if flags.dataSource == "auto" {
+ fmt.Fprintf(cmd.ErrOrStderr(), "This API has no search endpoint. Searching local data.\n")
+ }
+
+ // Local FTS search
+ if dbPath == "" {
+ dbPath = defaultDBPath("human-goat-pp-cli")
+ }
+
+ db, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w\nRun 'human-goat-pp-cli sync' first to populate the local database.", err)
+ }
+ defer db.Close()
+
+ maybeEmitSyncHints(cmd, db, resourceType, flags.maxAge)
+
+ var results []json.RawMessage
+ switch resourceType {
+ case "":
+ // Search every FTS-enabled source — typed per-resource tables
+ // AND the generic resources_fts — and dedup by raw JSON so a
+ // row indexed in multiple FTS sources appears once. Without
+ // the generic-search call, rows that landed in resources_fts
+ // but not in any typed FTS table (e.g., a resource whose sync
+ // populated only the generic index) silently return zero.
+ seen := make(map[string]bool)
+ _ = seen // prevent unused error when no FTS tables exist
+ {
+ partial, searchErr := db.Search(query, limit)
+ if searchErr != nil {
+ return fmt.Errorf("search resources_fts failed: %w", searchErr)
+ }
+ for _, r := range partial {
+ key := string(r)
+ if !seen[key] {
+ seen[key] = true
+ results = append(results, r)
+ }
+ }
+ }
+ default:
+ // Unrecognized type -- filter generic resources by type.
+ results, err = db.Search(query, limit, resourceType)
+ }
+ if err != nil {
+ return fmt.Errorf("search failed: %w", err)
+ }
+
+ reason := "user_requested"
+ if flags.dataSource != "local" {
+ reason = "no_search_endpoint"
+ }
+ prov := localProvenance(db, "search", reason)
+
+ return outputSearchResults(cmd, flags, results, limit, prov)
+ },
+ }
+
+ cmd.Flags().StringVar(&resourceType, "type", "", "Filter by resource type")
+ cmd.Flags().IntVar(&limit, "limit", 50, "Maximum results to return")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+
+ return cmd
+}
+
+// outputSearchResults filters, counts, and outputs search results with provenance.
+func outputSearchResults(cmd *cobra.Command, flags *rootFlags, results []json.RawMessage, limit int, prov DataProvenance) error {
+ // Filter out entries with nil or empty identifier fields.
+ filtered := make([]json.RawMessage, 0, len(results))
+ for _, r := range results {
+ if !isNilOrEmpty(r) {
+ filtered = append(filtered, r)
+ }
+ }
+ results = filtered
+
+ // Enforce limit across aggregated results.
+ if len(results) > limit {
+ results = results[:limit]
+ }
+
+ // Machine/piped mode always emits a valid envelope, including on no
+ // matches — agents pipe stdout through json.loads / jq and need parseable
+ // output regardless of result count. Explicit row formats render the result
+ // array directly so --csv/--plain can produce real tabular output.
+ if !wantsHumanTable(cmd.OutOrStdout(), flags) {
+ data, err := json.Marshal(results)
+ if err != nil {
+ return err
+ }
+ if flags.csv || flags.plain || flags.quiet {
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ }
+ outputFlags := *flags
+ if flags.selectFields != "" {
+ data = filterFields(data, flags.selectFields)
+ outputFlags.selectFields = ""
+ outputFlags.compact = false
+ } else if flags.compact {
+ data = compactFields(data)
+ outputFlags.compact = false
+ }
+ wrapped, err := wrapWithProvenance(data, prov)
+ if err != nil {
+ return err
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), wrapped, &outputFlags)
+ }
+
+ if len(results) == 0 {
+ fmt.Fprintf(cmd.ErrOrStderr(), "No results (source: %s)\n", prov.Source)
+ return nil
+ }
+
+ printProvenance(cmd, len(results), prov)
+ for _, r := range results {
+ fmt.Fprintln(cmd.OutOrStdout(), string(r))
+ }
+ return nil
+}
diff --git a/library/productivity/human-goat/internal/cli/send.go b/library/productivity/human-goat/internal/cli/send.go
new file mode 100644
index 0000000000..8d085469f4
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/send.go
@@ -0,0 +1,123 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+// pp:data-source live
+
+package cli
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/magic"
+)
+
+type magicRequestOutput struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Title string `json:"title"`
+}
+
+func newMagicSendCmd(flags *rootFlags) *cobra.Command {
+ var title string
+ var instructions string
+ var objective string
+ var maxMinutes int
+ var relaxed bool
+ var tag string
+
+ cmd := &cobra.Command{
+ Use: "send --title T --instructions I --objective O",
+ Short: "Send a remote errand to Magic",
+ Example: ` human-goat-pp-cli send --title "Find a plumber" --instructions "Call three plumbers in Oakland" --objective "Return names, availability, and prices"
+ human-goat-pp-cli send --title "Research" --instructions "Find stores that carry this item" --objective "Return three options" --max-minutes 30 --tag shopping`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && cmd.Flags().NFlag() == 0 {
+ return cmd.Help()
+ }
+ title = strings.TrimSpace(title)
+ instructions = strings.TrimSpace(instructions)
+ objective = strings.TrimSpace(objective)
+ tag = strings.TrimSpace(tag)
+ if dryRunOK(flags) || cliutil.IsVerifyEnv() || cliutil.IsDogfoodEnv() {
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{"dry_run": true, "would_dispatch": title}, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "would dispatch Magic task: %s\n", title)
+ return nil
+ }
+ if len(args) > 0 {
+ return usageErr(fmt.Errorf("send does not accept positional arguments; use --title, --instructions, and --objective"))
+ }
+ if title == "" {
+ return usageErr(fmt.Errorf("--title is required"))
+ }
+ if instructions == "" {
+ return usageErr(fmt.Errorf("--instructions is required"))
+ }
+ if objective == "" {
+ return usageErr(fmt.Errorf("--objective is required"))
+ }
+ var maxMinutesPtr *int
+ if cmd.Flags().Changed("max-minutes") {
+ if maxMinutes < 0 {
+ return usageErr(fmt.Errorf("--max-minutes must be non-negative"))
+ }
+ maxMinutesPtr = &maxMinutes
+ }
+
+ ctx, cancel := boundCtx(cmd.Context(), flags)
+ defer cancel()
+
+ client, err := magic.NewClient()
+ if err != nil {
+ return fmt.Errorf("initialize Magic client: %w", err)
+ }
+ req, err := client.Send(ctx, magic.SendParams{
+ Title: title,
+ Instructions: instructions,
+ Objective: objective,
+ MaxMinutes: maxMinutesPtr,
+ Relaxed: relaxed,
+ Tag: tag,
+ })
+ if err != nil {
+ return fmt.Errorf("send Magic task: %w", err)
+ }
+ persistMagicRequest(ctx, req)
+ out := magicRequestSummary(req)
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ printMagicRequestSummary(cmd, out)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&title, "title", "", "Short title for the Magic task")
+ cmd.Flags().StringVar(&instructions, "instructions", "", "Detailed instructions for the human operator")
+ cmd.Flags().StringVar(&objective, "objective", "", "Desired final outcome")
+ cmd.Flags().IntVar(&maxMinutes, "max-minutes", 0, "Maximum operator time budget in minutes")
+ cmd.Flags().BoolVar(&relaxed, "relaxed", false, "Allow Magic to complete the task with a relaxed time expectation")
+ cmd.Flags().StringVar(&tag, "tag", "", "Optional callback metadata tag")
+ return cmd
+}
+
+func magicRequestSummary(req *magic.Request) magicRequestOutput {
+ if req == nil {
+ return magicRequestOutput{}
+ }
+ return magicRequestOutput{
+ ID: req.ID,
+ Status: req.Status,
+ Title: req.Title,
+ }
+}
+
+func printMagicRequestSummary(cmd *cobra.Command, req magicRequestOutput) {
+ w := cmd.OutOrStdout()
+ fmt.Fprintf(w, "id: %s\n", req.ID)
+ fmt.Fprintf(w, "status: %s\n", req.Status)
+ fmt.Fprintf(w, "title: %s\n", req.Title)
+}
diff --git a/library/productivity/human-goat/internal/cli/spend.go b/library/productivity/human-goat/internal/cli/spend.go
new file mode 100644
index 0000000000..83893e0d00
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/spend.go
@@ -0,0 +1,430 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// pp:data-source local
+// Novel command scaffold. Implement the RunE body before shipping.
+// generate --force preserves implemented bodies; untouched TODO scaffolds may refresh.
+
+package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/pricing"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+func newNovelSpendCmd(flags *rootFlags) *cobra.Command {
+ var flagBy string
+ var flagState string
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "spend",
+ Short: "SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month",
+ Example: " human-goat-pp-cli spend --by source --agent",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && !commandHasChangedFlags(cmd) {
+ return cmd.Help()
+ }
+ if dryRunOK(flags) {
+ fmt.Fprintln(cmd.OutOrStdout(), "would aggregate local spend")
+ return nil
+ }
+ if len(args) > 0 {
+ return usageErr(fmt.Errorf("spend does not accept positional arguments"))
+ }
+ flagBy = strings.ToLower(strings.TrimSpace(flagBy))
+ switch flagBy {
+ case "source", "category", "tasker", "month":
+ default:
+ return usageErr(fmt.Errorf("--by must be one of source, category, tasker, month"))
+ }
+
+ if dbPath == "" {
+ dbPath = defaultDBPath("human-goat-pp-cli")
+ }
+ if _, err := os.Stat(dbPath); err != nil {
+ if os.IsNotExist(err) {
+ fmt.Fprintf(cmd.ErrOrStderr(), "no local mirror at %s\nrun: human-goat-pp-cli sync --db %s\n", dbPath, dbPath)
+ if flags.asJSON || flags.agent {
+ fmt.Fprintln(cmd.OutOrStdout(), "[]")
+ }
+ return nil
+ }
+ return fmt.Errorf("stat local mirror: %w", err)
+ }
+
+ s, err := store.OpenReadOnlyContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local mirror: %w", err)
+ }
+ defer s.Close()
+
+ rows, err := s.Query(`SELECT resource_type, data, updated_at FROM resources WHERE resource_type IN ('bookings', 'invoices', 'magic', 'tasks')`)
+ if err != nil {
+ return fmt.Errorf("query local spend rows: %w", err)
+ }
+
+ resourceRows := make([]spendResourceRow, 0)
+ for rows.Next() {
+ var row spendResourceRow
+ var data []byte
+ if err := rows.Scan(&row.ResourceType, &data, &row.UpdatedAt); err != nil {
+ _ = rows.Close()
+ return fmt.Errorf("scan local spend row: %w", err)
+ }
+ row.Data = append(json.RawMessage(nil), data...)
+ resourceRows = append(resourceRows, row)
+ }
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ return fmt.Errorf("read local spend rows: %w", err)
+ }
+ if err := rows.Close(); err != nil {
+ return fmt.Errorf("close local spend rows: %w", err)
+ }
+
+ out := aggregateSpendRows(resourceRows, flagBy, flagState)
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ tableRows := make([][]string, 0, len(out))
+ for _, row := range out {
+ tableRows = append(tableRows, []string{row.Group, strconv.Itoa(row.Count), strconv.Itoa(row.TotalCents)})
+ }
+ if err := flags.printTable(cmd, []string{"GROUP", "COUNT", "TOTAL_CENTS"}, tableRows); err != nil {
+ return err
+ }
+ if len(out) == 0 {
+ fmt.Fprintln(cmd.ErrOrStderr(), "no local spend data yet; sync bookings/invoices first.")
+ }
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&flagBy, "by", "source", "Aggregate by source, category, tasker, or month")
+ cmd.Flags().StringVar(&flagState, "state", "", "State for CA/MA service-fee-only pricing when estimating TaskRabbit all-in totals")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
+
+type spendResourceRow struct {
+ ResourceType string
+ Data json.RawMessage
+ UpdatedAt string
+}
+
+type spendOutputRow struct {
+ Group string `json:"group"`
+ Count int `json:"count"`
+ TotalCents int `json:"total_cents"`
+}
+
+func aggregateSpendRows(rows []spendResourceRow, by, state string) []spendOutputRow {
+ type aggregate struct {
+ count int
+ totalCents int
+ }
+ aggregates := make(map[string]aggregate)
+ for _, row := range rows {
+ decoded := decodeSpendData(row.Data)
+ group := spendGroup(row, decoded, by)
+ if group == "" {
+ group = "unknown"
+ }
+ agg := aggregates[group]
+ agg.count++
+ if cents, ok := spendAmountCentsForRow(row, decoded, state); ok {
+ agg.totalCents += cents
+ }
+ aggregates[group] = agg
+ }
+
+ groups := make([]string, 0, len(aggregates))
+ for group := range aggregates {
+ groups = append(groups, group)
+ }
+ sort.Strings(groups)
+
+ out := make([]spendOutputRow, 0, len(groups))
+ for _, group := range groups {
+ agg := aggregates[group]
+ out = append(out, spendOutputRow{
+ Group: group,
+ Count: agg.count,
+ TotalCents: agg.totalCents,
+ })
+ }
+ return out
+}
+
+func decodeSpendData(data json.RawMessage) any {
+ if len(bytes.TrimSpace(data)) == 0 {
+ return nil
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.UseNumber()
+ var decoded any
+ if err := decoder.Decode(&decoded); err != nil {
+ return nil
+ }
+ return decoded
+}
+
+func spendGroup(row spendResourceRow, decoded any, by string) string {
+ switch by {
+ case "source":
+ if row.ResourceType == "magic" {
+ return "magic"
+ }
+ return "taskrabbit"
+ case "category":
+ return findSpendString(decoded, "category", "category_name", "categoryName", "task_template", "task_template_name", "taskTemplateName")
+ case "tasker":
+ return findSpendString(decoded, "tasker", "tasker_name", "taskerName", "rabbit_name", "rabbitName", "display_name", "displayName")
+ case "month":
+ if month := spendMonth(findSpendString(decoded, "date", "created_at", "createdAt", "completed_at", "completedAt", "submitted_at", "submittedAt", "scheduled_at", "scheduledAt", "invoice_date", "invoiceDate", "updated_at", "updatedAt")); month != "" {
+ return month
+ }
+ return spendMonth(row.UpdatedAt)
+ default:
+ return ""
+ }
+}
+
+func findSpendString(value any, keys ...string) string {
+ targets := make(map[string]bool, len(keys))
+ for _, key := range keys {
+ targets[normalizeSpendKey(key)] = true
+ }
+ return findSpendStringInValue(value, targets)
+}
+
+func findSpendStringInValue(value any, targets map[string]bool) string {
+ switch v := value.(type) {
+ case map[string]any:
+ for key, item := range v {
+ if !targets[normalizeSpendKey(key)] {
+ continue
+ }
+ if s := spendStringValue(item); s != "" {
+ return s
+ }
+ if s := findSpendString(item, "name", "title", "display_name", "displayName"); s != "" {
+ return s
+ }
+ }
+ for _, item := range v {
+ if s := findSpendStringInValue(item, targets); s != "" {
+ return s
+ }
+ }
+ case []any:
+ for _, item := range v {
+ if s := findSpendStringInValue(item, targets); s != "" {
+ return s
+ }
+ }
+ }
+ return ""
+}
+
+func spendStringValue(value any) string {
+ switch v := value.(type) {
+ case string:
+ return strings.TrimSpace(v)
+ case json.Number:
+ return strings.TrimSpace(v.String())
+ default:
+ return ""
+ }
+}
+
+// spendAmountCentsForRow resolves the spend amount for a row. An explicit
+// billed/all-in amount (e.g. an invoice row's real total) always wins so a rate
+// estimate never shadows a known charge. Only when no explicit amount exists
+// does it estimate a TaskRabbit booking's all-in total: base hourly rate ->
+// all-in fee transform -> multiplied by the booked duration (so multi-hour
+// bookings are not counted at a single hour).
+func spendAmountCentsForRow(row spendResourceRow, decoded any, state string) (int, bool) {
+ if cents, ok := findSpendAmountCents(decoded); ok {
+ return cents, true
+ }
+ if row.ResourceType != "magic" {
+ if base, ok := findPosterHourlyRateCents(decoded); ok {
+ allInHourly := pricing.AllIn(base, state).AllInCents
+ hours := findBookingHours(decoded)
+ return int(math.Round(float64(allInHourly) * hours)), true
+ }
+ }
+ return 0, false
+}
+
+// findBookingHours derives a TaskRabbit booking's billable hours from a
+// duration_seconds or hours field, defaulting to 1 hour (the TaskRabbit
+// minimum billing unit) when neither is present.
+func findBookingHours(value any) float64 {
+ if secs, ok := findNumericField(value, "duration_seconds"); ok && secs > 0 {
+ if h := secs / 3600.0; h >= 1 {
+ return h
+ }
+ }
+ if h, ok := findNumericField(value, "hours"); ok && h >= 1 {
+ return h
+ }
+ return 1
+}
+
+// findNumericField recursively finds the first numeric value for key.
+func findNumericField(value any, key string) (float64, bool) {
+ switch v := value.(type) {
+ case map[string]any:
+ if item, ok := v[key]; ok {
+ if raw, _, ok := spendNumericValue(item); ok {
+ return raw, true
+ }
+ }
+ for _, item := range v {
+ if raw, ok := findNumericField(item, key); ok {
+ return raw, true
+ }
+ }
+ case []any:
+ for _, item := range v {
+ if raw, ok := findNumericField(item, key); ok {
+ return raw, true
+ }
+ }
+ }
+ return 0, false
+}
+
+// findPosterHourlyRateCents extracts a TaskRabbit poster_hourly_rate_cents value
+// (the pre-fee base rate) from a decoded row, recursing into nested objects.
+func findPosterHourlyRateCents(value any) (int, bool) {
+ switch v := value.(type) {
+ case map[string]any:
+ if item, ok := v["poster_hourly_rate_cents"]; ok {
+ if cents, ok := spendCentsValue("poster_hourly_rate_cents", item); ok {
+ return cents, true
+ }
+ }
+ for _, item := range v {
+ if cents, ok := findPosterHourlyRateCents(item); ok {
+ return cents, true
+ }
+ }
+ case []any:
+ for _, item := range v {
+ if cents, ok := findPosterHourlyRateCents(item); ok {
+ return cents, true
+ }
+ }
+ }
+ return 0, false
+}
+
+func findSpendAmountCents(value any) (int, bool) {
+ switch v := value.(type) {
+ case map[string]any:
+ for key, item := range v {
+ if spendAmountKey(key) {
+ if cents, ok := spendCentsValue(key, item); ok {
+ return cents, true
+ }
+ }
+ }
+ for _, item := range v {
+ if cents, ok := findSpendAmountCents(item); ok {
+ return cents, true
+ }
+ }
+ case []any:
+ for _, item := range v {
+ if cents, ok := findSpendAmountCents(item); ok {
+ return cents, true
+ }
+ }
+ }
+ return 0, false
+}
+
+func spendAmountKey(key string) bool {
+ normalized := normalizeSpendKey(key)
+ return normalized == "amount" || normalized == "allin" || normalized == "amountcents" || normalized == "allincents"
+}
+
+func spendCentsValue(key string, value any) (int, bool) {
+ raw, stringValue, ok := spendNumericValue(value)
+ if !ok {
+ return 0, false
+ }
+ normalized := normalizeSpendKey(key)
+ if strings.HasSuffix(normalized, "cents") {
+ return roundSpendCents(raw), true
+ }
+ if strings.Contains(stringValue, ".") || strings.Contains(stringValue, "$") {
+ return roundSpendCents(raw * 100), true
+ }
+ return roundSpendCents(raw), true
+}
+
+func spendNumericValue(value any) (float64, string, bool) {
+ switch v := value.(type) {
+ case json.Number:
+ f, err := v.Float64()
+ return f, v.String(), err == nil
+ case float64:
+ return v, strconv.FormatFloat(v, 'f', -1, 64), true
+ case int:
+ return float64(v), strconv.Itoa(v), true
+ case string:
+ clean := strings.TrimSpace(v)
+ clean = strings.TrimPrefix(clean, "$")
+ clean = strings.ReplaceAll(clean, ",", "")
+ clean = strings.TrimSuffix(clean, "/hr")
+ clean = strings.TrimSpace(clean)
+ f, err := strconv.ParseFloat(clean, 64)
+ return f, v, err == nil
+ default:
+ return 0, "", false
+ }
+}
+
+func roundSpendCents(v float64) int {
+ if v < 0 {
+ return int(v - 0.5)
+ }
+ return int(v + 0.5)
+}
+
+func normalizeSpendKey(key string) string {
+ key = strings.ToLower(strings.TrimSpace(key))
+ replacer := strings.NewReplacer("_", "", "-", "", " ", "")
+ return replacer.Replace(key)
+}
+
+func spendMonth(value string) string {
+ value = strings.TrimSpace(value)
+ if len(value) >= len("2006-01") && value[4] == '-' {
+ if _, err := time.Parse("2006-01", value[:7]); err == nil {
+ return value[:7]
+ }
+ }
+ layouts := []string{time.RFC3339, "2006-01-02", "2006-01-02 15:04:05", "2006-01-02T15:04:05"}
+ for _, layout := range layouts {
+ if t, err := time.Parse(layout, value); err == nil {
+ return t.Format("2006-01")
+ }
+ }
+ return ""
+}
diff --git a/library/productivity/human-goat/internal/cli/spend_test.go b/library/productivity/human-goat/internal/cli/spend_test.go
new file mode 100644
index 0000000000..8517e7afaa
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/spend_test.go
@@ -0,0 +1,32 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// cli-printing-press: novel-scaffold-test
+// Novel command scaffold tests. Keep the wiring smoke test and add behavior cases as needed.
+
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// TestNovelSpendHelpWires smoke-tests that the spend command
+// resolves at runtime and renders useful --help output. Catches wiring
+// regressions (missing AddCommand, panicking RunE on --help, etc.) before
+// review. Keep this smoke test when adding behavior-specific cases.
+func TestNovelSpendHelpWires(t *testing.T) {
+ cmd := RootCmd()
+ cmd.SetArgs([]string{"spend", "--help"})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("spend --help error = %v (novel command not wired correctly?)", err)
+ }
+ help := out.String()
+ for _, want := range []string{"Usage:", "spend"} {
+ if !strings.Contains(help, want) {
+ t.Fatalf("spend --help missing %q in output:\n%s", want, help)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/status.go b/library/productivity/human-goat/internal/cli/status.go
new file mode 100644
index 0000000000..61ea83bc22
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/status.go
@@ -0,0 +1,186 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// pp:data-source live
+// Novel command scaffold. Implement the RunE body before shipping.
+// generate --force preserves implemented bodies; untouched TODO scaffolds may refresh.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/magic"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/taskrabbit"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+func newNovelStatusCmd(flags *rootFlags) *cobra.Command {
+ var flagOpen bool
+
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "One list of every in-flight task across TaskRabbit bookings and Magic requests",
+ Example: " human-goat-pp-cli status --open --agent",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // status takes no required flags or args — a bare `status` is its
+ // primary use, so it must execute rather than print help by default.
+ if dryRunOK(flags) {
+ fmt.Fprintln(cmd.OutOrStdout(), "would list in-flight tasks across sources")
+ return nil
+ }
+ if len(args) > 0 {
+ return usageErr(fmt.Errorf("status does not accept positional arguments"))
+ }
+
+ out := statusOutput{Tasks: make([]statusRow, 0)}
+
+ // TaskRabbit: page through all current bookings rather than the first page.
+ c, err := flags.newClient()
+ if err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "taskrabbit: %v\n", err)
+ } else {
+ tr := taskrabbit.New(c)
+ // --open scopes the TaskRabbit listing to active bookings too, not
+ // just the Magic side; without this filter status --open returns all
+ // historical bookings.
+ trFilter := map[string]any{}
+ if flagOpen {
+ trFilter["status"] = "active"
+ }
+ bookings, err := listAllBookings(cmd.Context(), tr, trFilter)
+ if err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "taskrabbit: %v\n", err)
+ } else {
+ for _, booking := range bookings {
+ out.Tasks = append(out.Tasks, statusRow{
+ Source: "taskrabbit",
+ ID: booking.ID,
+ Status: booking.Status,
+ Title: "",
+ })
+ }
+ }
+ }
+
+ // Magic: the API has no list endpoint, so the local store is the inbox.
+ // Read the request IDs recorded by send/call/dispatch, then refresh each
+ // one live so the reported status reflects current progress.
+ magicRows, magicNote := magicStatusRows(cmd, flagOpen)
+ out.Tasks = append(out.Tasks, magicRows...)
+ out.MagicNote = magicNote
+
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ tableRows := make([][]string, 0, len(out.Tasks))
+ for _, row := range out.Tasks {
+ tableRows = append(tableRows, []string{row.Source, row.ID, row.Status, row.Title})
+ }
+ if err := flags.printTable(cmd, []string{"SOURCE", "ID", "STATUS", "TITLE"}, tableRows); err != nil {
+ return err
+ }
+ if out.MagicNote != "" {
+ fmt.Fprintln(cmd.ErrOrStderr(), out.MagicNote)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&flagOpen, "open", false, "Show only in-flight work: narrows Magic requests to in-progress ones (TaskRabbit's task list is active-only by API design, so its rows are always in-flight)")
+ return cmd
+}
+
+// listAllBookings pages through TaskRabbit bookings so status is not capped at
+// the first page. Bounded so a paging bug can't loop forever.
+func listAllBookings(ctx context.Context, tr *taskrabbit.Client, filter map[string]any) ([]taskrabbit.Booking, error) {
+ const perPage = 50
+ const maxPages = 40
+ var all []taskrabbit.Booking
+ for page := 1; page <= maxPages; page++ {
+ batch, err := tr.ListTasks(ctx, page, perPage, filter, "en-US")
+ if err != nil {
+ return nil, err
+ }
+ all = append(all, batch...)
+ if len(batch) < perPage {
+ break
+ }
+ }
+ return all, nil
+}
+
+// magicStatusRows loads the locally-recorded Magic requests and refreshes each
+// one live. It returns the rows plus a human-facing note describing why the
+// list may be empty (no store, no API key, or nothing tracked yet).
+func magicStatusRows(cmd *cobra.Command, openOnly bool) ([]statusRow, string) {
+ dbPath := defaultDBPath("human-goat-pp-cli")
+ if _, err := os.Stat(dbPath); err != nil {
+ return nil, "Magic: no locally tracked requests yet (send/call/dispatch record them here)."
+ }
+ db, err := store.OpenReadOnlyContext(cmd.Context(), dbPath)
+ if err != nil {
+ return nil, fmt.Sprintf("Magic: could not open local store: %v", err)
+ }
+ defer db.Close()
+
+ rows, err := db.Query(`SELECT data FROM resources WHERE resource_type = 'magic'`)
+ if err != nil {
+ return nil, fmt.Sprintf("Magic: could not read local store: %v", err)
+ }
+ var ids []string
+ for rows.Next() {
+ var data []byte
+ if err := rows.Scan(&data); err != nil {
+ _ = rows.Close()
+ return nil, fmt.Sprintf("Magic: could not scan local store: %v", err)
+ }
+ var stored magic.Request
+ if err := json.Unmarshal(data, &stored); err == nil && stored.ID != "" {
+ ids = append(ids, stored.ID)
+ }
+ }
+ _ = rows.Close()
+ if len(ids) == 0 {
+ return nil, "Magic: no locally tracked requests yet (send/call/dispatch record them here)."
+ }
+
+ client, err := magic.NewClient()
+ if err != nil {
+ return nil, fmt.Sprintf("Magic: %d tracked request(s), but live refresh unavailable: %v", len(ids), err)
+ }
+
+ out := make([]statusRow, 0, len(ids))
+ for _, id := range ids {
+ req, err := client.GetRequest(cmd.Context(), id)
+ if err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "magic %s: %v\n", id, err)
+ continue
+ }
+ if openOnly && !magic.IsInProgress(req.Status) {
+ continue
+ }
+ out = append(out, statusRow{
+ Source: "magic",
+ ID: req.ID,
+ Status: req.Status,
+ Title: req.Title,
+ })
+ }
+ return out, ""
+}
+
+type statusOutput struct {
+ Tasks []statusRow `json:"tasks"`
+ MagicNote string `json:"magic_note,omitempty"`
+}
+
+type statusRow struct {
+ Source string `json:"source"`
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Title string `json:"title"`
+}
diff --git a/library/productivity/human-goat/internal/cli/status_test.go b/library/productivity/human-goat/internal/cli/status_test.go
new file mode 100644
index 0000000000..22ede9a9ed
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/status_test.go
@@ -0,0 +1,32 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// cli-printing-press: novel-scaffold-test
+// Novel command scaffold tests. Keep the wiring smoke test and add behavior cases as needed.
+
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// TestNovelStatusHelpWires smoke-tests that the status command
+// resolves at runtime and renders useful --help output. Catches wiring
+// regressions (missing AddCommand, panicking RunE on --help, etc.) before
+// review. Keep this smoke test when adding behavior-specific cases.
+func TestNovelStatusHelpWires(t *testing.T) {
+ cmd := RootCmd()
+ cmd.SetArgs([]string{"status", "--help"})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("status --help error = %v (novel command not wired correctly?)", err)
+ }
+ help := out.String()
+ for _, want := range []string{"Usage:", "status"} {
+ if !strings.Contains(help, want) {
+ t.Fatalf("status --help missing %q in output:\n%s", want, help)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/sync.go b/library/productivity/human-goat/internal/cli/sync.go
new file mode 100644
index 0000000000..0647aca471
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/sync.go
@@ -0,0 +1,1998 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/lookups"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+ "github.com/spf13/cobra"
+ "io"
+ "net/url"
+ "os"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// unresolvedPathKeyRE matches `{key}` placeholders left in a sync path
+// after syncResourcePath() resolution. Hierarchical APIs (Yahoo Fantasy,
+// Reddit pre-2024, YouTube Data v3, MLB Stats, etc.) declare paths like
+// "/league/{league_key}/players" that can only be filled from parent
+// context — flat-list sync cannot fill them. Resources with unresolved
+// keys emit sync_warning and are skipped without aborting the run, so
+// sync still completes for resources that DO have resolvable paths.
+var unresolvedPathKeyRE = regexp.MustCompile(`\{[a-zA-Z_][a-zA-Z0-9_]*\}`)
+
+// syncResult holds the outcome of syncing a single resource.
+type syncResult struct {
+ Resource string
+ Count int
+ Err error
+ Warn error
+ Duration time.Duration
+}
+
+func newSyncCmd(flags *rootFlags) *cobra.Command {
+ var resources []string
+ var full bool
+ var noPrune bool
+ var since string
+ var concurrency int
+ var dbPath string
+ var maxPages int
+ var latestOnly bool
+ var strict bool
+ var paramFlags []string
+ var resourceParamFlags []string
+ var globalParamFlags []string
+
+ cmd := &cobra.Command{
+ Use: "sync",
+ Short: "Sync API data to local SQLite for offline search and analysis",
+ Long: `Sync data from the API into a local SQLite database. This API does
+not declare temporal sync filters, so sync performs full pagination unless an
+explicit resource declares its own incremental filter.
+Once synced, use the 'search' command for instant full-text search.
+
+Exit codes & warnings:
+ Resources the API denies access to (HTTP 403, or HTTP 400 with an
+ access-policy body) are reported as warnings rather than failing the
+ run. In --json mode each is emitted as a {"event":"sync_warning",...}
+ line carrying status, reason, and message fields, and a final
+ {"event":"sync_summary",...} aggregates the run.
+
+ Exit 0 when at least one resource synced and no resource flagged in
+ the spec as critical (x-critical: true) failed; non-critical failures
+ emit {"event":"sync_warning","reason":"exit_policy_default_changed",
+ ...} so callers can detect that a partial failure was tolerated. Pass
+ --strict to exit non-zero on any per-resource failure. Exit is always
+ non-zero when every selected resource failed, regardless of --strict.
+
+Resource scoping:
+ --resources runs the named top-level resources, plus any parent-keyed
+ dependent whose own name is listed OR whose parent table is listed.
+ Naming a parent therefore cascades to its dependents (so "sync this
+ parent and its children" works without listing every nested resource
+ by hand). There is no flag today to suppress the cascade for a named
+ parent. To run a dependent without re-syncing its parent, list only
+ the dependent by name; the parent table must already be populated
+ from a prior sync.`,
+ Example: ` # Sync all resources
+ human-goat-pp-cli sync
+
+ # Sync specific resources only
+ human-goat-pp-cli sync --resources channels,messages
+
+ # Full resync (ignore previous checkpoint)
+ human-goat-pp-cli sync --full
+
+ # Incremental sync: only records from the last 7 days
+ human-goat-pp-cli sync --since 7d
+
+ # Parallel sync with 8 workers
+ human-goat-pp-cli sync --concurrency 8
+
+ # Latest-only: refresh head of each resource, no historical backfill
+ human-goat-pp-cli sync --latest-only`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
+ if err != nil {
+ return usageErr(err)
+ }
+
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c.NoCache = true
+
+ if dbPath == "" {
+ dbPath = defaultDBPath("human-goat-pp-cli")
+ }
+
+ db, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w", err)
+ }
+ defer db.Close()
+
+ syncEventWriter := cmd.OutOrStdout()
+
+ // If no specific resources, sync top-level resources
+ if len(resources) == 0 {
+ resources = defaultSyncResources()
+ }
+
+ // Reject --resource-param keys that don't match a known resource.
+ // Validates against the full top-level + dependent set, not the
+ // user-filtered `resources` slice, so legitimate cases like
+ // "filter to A, but apply param to B if it gets synced" still
+ // catch typos without false positives.
+ if err := userParams.validateResourceNames(knownSyncResourceNames()); err != nil {
+ return usageErr(err)
+ }
+
+ // --full: clear all sync cursors before starting.
+ // Skip under --dry-run: a preview must not mutate sync-state (issue #2935).
+ if full && !c.DryRun {
+ for _, resource := range resources {
+ _ = db.SaveSyncState(resource, "", 0)
+ }
+ }
+
+ if cliutil.IsDogfoodEnv() && !cmd.Flags().Changed("max-pages") {
+ maxPages = 1
+ }
+
+ // --latest-only narrows to the first page of each resource
+ // ignoring the historical resume cursor. We cap maxPages at 1
+ // here rather than re-interpreting it downstream so the rest
+ // of the sync loop stays oblivious. Mutually-useful with
+ // --since: if the user set --since, that threshold still wins
+ // and we don't short-circuit historical context they asked for.
+ if latestOnly {
+ if since == "" {
+ maxPages = 1
+ // Clear the cursor so we start from the head each time;
+ // the goal of --latest-only is "refresh the top" not
+ // "resume from wherever I left off". Skip under --dry-run:
+ // a preview must not mutate sync-state (issue #2935).
+ if !c.DryRun {
+ for _, resource := range resources {
+ existing, _, _, _ := db.GetSyncState(resource)
+ if existing != "" {
+ _ = db.SaveSyncState(resource, "", 0)
+ }
+ }
+ }
+ } else if humanFriendly {
+ fmt.Fprintln(os.Stderr, "warning: --latest-only ignored because --since is set; --since takes precedence")
+ }
+ }
+ // effectiveLatestOnly drives the max_pages_cap_hit suppression
+ // below. It must reflect whether --latest-only is actually the
+ // cap source, i.e. only when --since is empty. If --since wins
+ // (block above), --latest-only is a no-op for maxPages and any
+ // cap hit reflects an explicit operator limit or the dogfood
+ // safety limit, which is a real anomaly worth surfacing.
+ effectiveLatestOnly := latestOnly && since == ""
+
+ // Resolve --since into an RFC3339 timestamp
+ sinceTS := ""
+ if since != "" {
+ ts, err := parseSinceDuration(since)
+ if err != nil {
+ return fmt.Errorf("invalid --since value %q: %w", since, err)
+ }
+ sinceTS = ts.Format(time.RFC3339)
+ }
+
+ // Worker pool: produce resources, N workers consume
+ if concurrency < 1 {
+ concurrency = 4
+ }
+ // Under PRINTING_PRESS_VERIFY=1 (mock/dry-run), all goroutines
+ // reach SQLite without the natural serialization that network
+ // latency provides in real syncs, so the worker pool races on
+ // the writer and trips SQLITE_BUSY despite _busy_timeout.
+ if cliutil.IsVerifyEnv() {
+ concurrency = 1
+ }
+
+ started := time.Now()
+ // prune gates deletion reconciliation: a full sync prunes local rows
+ // the API no longer returns within a fully-enumerated partition, unless
+ // --no-prune disables it. Flat tenant-scoped reconcile (per resource)
+ // and dependent per-parent reconcile share this gate.
+ prune := full && !noPrune
+ work := make(chan string, len(resources))
+ results := make(chan syncResult, len(resources))
+
+ var wg sync.WaitGroup
+ for i := 0; i < concurrency; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for resource := range work {
+ res := syncResource(cmd.Context(), c, db, resource, sinceTS, full, maxPages, effectiveLatestOnly, prune, userParams, syncEventWriter)
+ results <- res
+ }
+ }()
+ }
+
+ // Enqueue all resources
+ for _, resource := range resources {
+ work <- resource
+ }
+ close(work)
+
+ // Collect results in a separate goroutine
+ go func() {
+ wg.Wait()
+ close(results)
+ }()
+
+ var totalSynced int
+ var errCount int
+ var criticalErrCount int
+ var warnCount int
+ var successCount int
+ var failedResources []string
+ var criticalFailedResources []string
+ var firstErr error
+ var firstPlaceholderErr error
+ for res := range results {
+ if res.Err != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: error: %v\n", res.Resource, res.Err)
+ }
+ errCount++
+ failedResources = append(failedResources, res.Resource)
+ if firstErr == nil {
+ firstErr = res.Err
+ }
+ if firstPlaceholderErr == nil && errors.Is(res.Err, client.ErrPlaceholderCredential) {
+ firstPlaceholderErr = res.Err
+ }
+ if criticalResources[res.Resource] {
+ criticalErrCount++
+ criticalFailedResources = append(criticalFailedResources, res.Resource)
+ }
+ } else if res.Warn != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: warning: %v\n", res.Resource, res.Warn)
+ }
+ warnCount++
+ } else {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: %d synced (done)\n", res.Resource, res.Count)
+ }
+ totalSynced += res.Count
+ successCount++
+ }
+ }
+
+ elapsed := time.Since(started)
+ totalResources := successCount + warnCount + errCount
+ if humanFriendly {
+ if warnCount > 0 {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+ totalSynced, totalResources, warnCount, elapsed.Seconds())
+ } else {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+ totalSynced, totalResources, elapsed.Seconds())
+ }
+ } else {
+ fmt.Fprintf(syncEventWriter, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
+ totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
+ }
+
+ // Lookup refresh (learn loop): derive entity_lookups rows under
+ // source='synced' from the freshly synced resources, filtered to
+ // entities recorded as recall lookup misses. Local data only —
+ // it reads the store this run just populated, never the network.
+ // The PersistentPreRunE learn hook skips `sync` via
+ // shouldSkipLearnHook, so this explicit post-sync call is the
+ // one wiring point for the staleness-heal loop.
+ if successCount > 0 && !c.DryRun && !noLearnActive(flags) && !cliutil.IsVerifyEnv() {
+ refreshLookupsFromSyncedStore(cmd.Context(), db.DB(), syncEventWriter)
+ }
+
+ // Exit-code policy:
+ // 1. --strict + any error -> non-zero (legacy contract)
+ // 2. any critical failure -> non-zero regardless of --strict
+ // 3. nothing synced -> non-zero (preserves "all-warned" / "all-errored" exit)
+ // 4. otherwise -> exit 0 (any data synced + no critical failed)
+ // When branch 4 suppresses what branch 1 would have rejected, emit a
+ // one-shot sync_warning with reason "exit_policy_default_changed" so
+ // CI scripts that depend on $? != 0 can discover the contract change
+ // without reading the CHANGELOG.
+ if firstPlaceholderErr != nil {
+ return classifyAPIError(firstPlaceholderErr, flags)
+ }
+ if strict && errCount > 0 {
+ return errors.New(describeFailedResources(errCount, failedResources))
+ }
+ if criticalErrCount > 0 {
+ return errors.New(describeCriticalFailedResources(criticalErrCount, criticalFailedResources))
+ }
+ if successCount == 0 {
+ if warnCount > 0 && errCount == 0 {
+ return fmt.Errorf("%d resource(s) completed with warnings but no successful syncs", warnCount)
+ }
+ if errCount > 0 {
+ return errors.New(describeFailedResources(errCount, failedResources))
+ }
+ }
+ if errCount > 0 && !strict && criticalErrCount == 0 && successCount > 0 {
+ if !humanFriendly {
+ msg := fmt.Sprintf("%d resource(s) failed but exit code is 0 because the new default treats non-critical failures as warnings. Pass --strict to restore the old behavior, or annotate critical resources with x-critical: true. See CHANGELOG.", errCount)
+ fmt.Fprintf(syncEventWriter, `{"event":"sync_warning","reason":"exit_policy_default_changed","errored":%d,"message":"%s"}`+"\n",
+ errCount, strings.ReplaceAll(msg, `"`, `\"`))
+ } else {
+ fmt.Fprintf(os.Stderr, "warning: %d resource(s) failed but exit code is 0 because the new default treats non-critical failures as warnings. Pass --strict to restore the old behavior, or annotate critical resources with x-critical: true.\n", errCount)
+ }
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().StringSliceVar(&resources, "resources", nil, "Comma-separated resource types to sync. Naming a parent also runs its parent-keyed dependents (see Long help for scoping).")
+ cmd.Flags().BoolVar(&full, "full", false, "Full resync (ignore previous checkpoint)")
+ cmd.Flags().BoolVar(&noPrune, "no-prune", false, "Disable deletion reconciliation on --full (by default a full sync prunes local rows the API no longer returns for a fully-enumerated parent partition)")
+ cmd.Flags().StringVar(&since, "since", "", "Incremental sync duration (e.g. 7d, 24h, 1w, 30m)")
+ cmd.Flags().IntVar(&concurrency, "concurrency", 4, "Number of parallel sync workers")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ cmd.Flags().IntVar(&maxPages, "max-pages", 0, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
+ cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
+ cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
+ cmd.Flags().StringArrayVar(¶mFlags, "param", nil, "Extra query param to inject into flat-list sync requests (repeatable, key=value). Skipped on path-scoped dependent requests so a top-level scope like workspace= does not double up on /parents//children calls. Use --global-param to inject everywhere. Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
+ cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param and --global-param when keys conflict.")
+ cmd.Flags().StringArrayVar(&globalParamFlags, "global-param", nil, "Extra query param to inject into every sync request including dependent path-scoped calls (repeatable, key=value). Use when an API requires a scope on every call regardless of path nesting.")
+
+ return cmd
+}
+
+// syncResource handles the full paginated sync of a single resource.
+// It resumes from the last cursor unless sinceTS or full mode overrides it.
+// channel_workflow.go.tmpl mirrors the trailing dates arg conditional;
+// keep both call sites in sync if this signature changes.
+func syncResource(ctx context.Context, c interface {
+ Get(context.Context, string, map[string]string) (json.RawMessage, error)
+ RateLimit() float64
+}, db *store.Store, resource, sinceTS string, full bool, maxPages int, latestOnly bool, prune bool, userParams *syncUserParams, syncEvents io.Writer) syncResult {
+ started := time.Now()
+ if syncEvents == nil {
+ syncEvents = io.Discard
+ }
+
+ if !humanFriendly {
+ fmt.Fprintf(syncEvents, `{"event":"sync_start","resource":"%s"}`+"\n", resource)
+ }
+
+ path, err := syncResourcePath(resource)
+ if err != nil {
+ return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
+ }
+
+ // Skip resources whose path template still contains unresolved `{key}`
+ // placeholders after syncResourcePath() resolution. These paths require
+ // parent context (league_key, team_key, channel_id, etc.) that flat-list
+ // sync cannot fill. Emit a sync_warning describing the missing keys and
+ // continue — sync exits 0 if any resource succeeded, so this keeps
+ // hierarchical-API CLIs functional for the resources they CAN sync flat.
+ if missingKeys := unresolvedPathKeyRE.FindAllString(path, -1); len(missingKeys) > 0 {
+ if !humanFriendly {
+ payload := struct {
+ Event string `json:"event"`
+ Resource string `json:"resource"`
+ Reason string `json:"reason"`
+ Keys []string `json:"keys"`
+ Path string `json:"path"`
+ Message string `json:"message"`
+ }{
+ Event: "sync_warning",
+ Resource: resource,
+ Reason: "unfilled_path_key",
+ Keys: missingKeys,
+ Path: path,
+ Message: fmt.Sprintf("path %s requires parent context (%s); resource skipped", path, strings.Join(missingKeys, ", ")),
+ }
+ payloadJSON, _ := json.Marshal(payload)
+ fmt.Fprintf(syncEvents, "%s\n", payloadJSON)
+ } else {
+ fmt.Fprintf(os.Stderr, " %s skipped (requires parent context: %s)\n",
+ resource, strings.Join(missingKeys, ", "))
+ }
+ return syncResult{
+ Resource: resource,
+ Warn: fmt.Errorf("skipped %s: unresolved path keys %v", resource, missingKeys),
+ Duration: time.Since(started),
+ }
+ }
+
+ var totalCount int
+
+ // Resume cursor from sync_state (unless --full cleared it)
+ existingCursor, lastSynced, _, _ := db.GetSyncState(resource)
+ if !full {
+ if storedCount, err := db.Count(resource); err == nil && storedCount == 0 {
+ existingCursor = ""
+ lastSynced = time.Time{}
+ }
+ }
+
+ // Determine the since param value:
+ // 1. Explicit --since flag takes priority
+ // 2. Otherwise use last_synced_at from sync_state for incremental sync
+ sinceParam := syncResourceSinceParam(resource)
+ effectiveSince := sinceTS
+ if effectiveSince == "" && !lastSynced.IsZero() && !full {
+ effectiveSince = lastSynced.Format(time.RFC3339)
+ }
+ // Resources whose list endpoint declares no temporal-filter parameter
+ // fall back to plain pagination — sending a synthetic since=... would
+ // reach the API as an unknown query param and (for strict APIs like
+ // Notion) fail the whole resource with a 400. Warn once per resource
+ // when the user expected incremental behavior.
+ if effectiveSince != "" && sinceParam == "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", resource)
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", resource)
+ }
+ effectiveSince = ""
+ }
+ if effectiveSince != "" {
+ effectiveSince = formatSyncSinceValue(effectiveSince, syncResourceSinceParamFormat(resource))
+ }
+
+ cursor := existingCursor
+ pageSize := determinePaginationDefaults(resource)
+ var progressCount int64
+ pagesFetched := 0
+ lastNextCursor := ""
+ capExitHit := false
+ capExitCursor := ""
+ // extractFailureTotal accumulates per-item primary-key extraction
+ // misses across pages within this resource sync. Resource-level
+ // concurrency is 1 (one goroutine per resource via the work channel)
+ // so this counter cannot race. We emit one primary_key_unresolved
+ // sync_anomaly per resource per run when there's at least one miss
+ // (rate-limited via the anomalyEmitted flag) and a roll-up
+ // all_items_failed_id_extraction event when 100% of a single page
+ // failed extraction.
+ var extractFailureTotal int
+ var consumedTotal int
+ anomalyEmitted := false
+
+ // Flat tenant-scoped reconcile bookkeeping (mirrors the dependent loop's
+ // partitionOutcome machinery). flatReconcilable gates all of it: only
+ // resources classified reconcileMode=="flat" collect seen IDs and prune.
+ // outcome.complete is set ONLY at proven natural ends; any abnormal break
+ // sets outcome.reason and leaves complete=false so the reconcile SKIPS.
+ flatReconcilable := resourceReconcileMode(resource) == "flat"
+ outcome := partitionOutcome{}
+ var seenIDs []string
+
+ for {
+ params := map[string]string{}
+
+ if resourceSupportsPagination(resource) {
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
+ }
+
+ // Set since filter
+ if effectiveSince != "" {
+ params[sinceParam] = effectiveSince
+ }
+ // Apply user-supplied --param / --resource-param overrides last so they
+ // win over spec-derived defaults (e.g. forcing mine=true on a list
+ // endpoint whose OpenAPI spec marks the filter optional).
+ userParams.applyTo(resource, params, false)
+
+ data, err := c.Get(ctx, path, params)
+ if err != nil {
+ if w, ok := isSyncAccessWarning(err); ok {
+ if !humanFriendly {
+ fmt.Fprintln(syncEvents, syncWarningJSON(resource, "", w.Status, w.Reason, w.Message))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Warn: fmt.Errorf("skipped due to insufficient access: %s (%s)", resource, w.Reason), Duration: time.Since(started)}
+ }
+ if !humanFriendly {
+ fmt.Fprintln(syncEvents, syncErrorJSON(resource, "", err))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf("fetching %s: %w", resource, err), Duration: time.Since(started)}
+ }
+
+ // Dry-run sentinel: client.dryRun returns `{"dry_run": true}` instead
+ // of a real response when --dry-run is set. The upsert path below
+ // would otherwise fail with "missing id for " because the
+ // sentinel has no items and no id; emit a synthetic success event so
+ // validate-narrative --full-examples (which auto-appends --dry-run)
+ // sees a clean exit.
+ if isDryRunResponse(data) {
+ if !humanFriendly {
+ fmt.Fprintf(syncEvents, `{"event":"sync_dryrun","resource":"%s"}`+"\n", resource)
+ }
+ return syncResult{Resource: resource, Count: 0, Duration: time.Since(started)}
+ }
+
+ // Try to extract items from the response.
+ // Strategy: try array first, then common wrapper keys.
+ items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam, responsePathForResource(resource, path)...)
+
+ // Page-int paginator fallback: when the API paginates by integer
+ // ?page=N and emits no body cursor, treat a full page as a signal
+ // to advance numerically. Without this the loop breaks after page
+ // 1 even though more pages exist (the original symptom in #1296).
+ // Guard on cursorType, not cursorParam name, so all canonical
+ // spellings (page / page_number / pageNumber / page[number]) work.
+ if pageSize.cursorType == "page" && nextCursor == "" && len(items) >= pageSize.limit && pageAllowsPageIntFallback(data) {
+ currentPage, _ := strconv.Atoi(cursor)
+ if currentPage < 1 {
+ currentPage = 1
+ }
+ nextCursor = strconv.Itoa(currentPage + 1)
+ hasMore = true
+ }
+ if pageSize.cursorType == "offset" && nextCursor == "" && len(items) >= pageSize.limit && pageAllowsPageIntFallback(data) {
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ hasMore = true
+ }
+
+ if len(items) == 0 && len(data) > 0 && !isJSONResponse(data) {
+ // Abnormal: a 200 with a non-JSON body means the page was not a
+ // trustworthy enumeration — leave outcome.complete=false so reconcile
+ // SKIPS. Reuse the dependent loop's reason string for cross-loop
+ // consistency.
+ outcome.reason = "non_json_200_body"
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s returned a 200 response with a non-JSON body; no rows were stored.\n", resource)
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"sync_anomaly","resource":"%s","reason":"non_json_200_body"}`+"\n", resource)
+ }
+ break
+ }
+
+ if len(items) == 0 {
+ if isEmptyPageResponse(data, responsePathForResource(resource, path)...) {
+ // Natural end: the API legitimately returned an empty page.
+ outcome.complete = true
+ break
+ }
+ // Single object response - try to store as-is
+ if err := upsertSingleObject(db, resource, data); err != nil {
+ if !humanFriendly {
+ fmt.Fprintln(syncEvents, syncErrorJSON(resource, "", err))
+ }
+ return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
+ }
+ totalCount++
+ // Single-object resources are fully enumerated by definition.
+ outcome.complete = true
+ break
+ }
+
+ // Batch upsert all items from this page. UpsertBatch returns
+ // (stored, extractFailures, err): stored counts rows actually
+ // landed; extractFailures counts items that survived JSON
+ // unmarshal but had no extractable primary key (templated
+ // IDField AND generic fallback both missed). Tracking these
+ // separately lets us emit precise sync_anomaly events: a
+ // roll-up "all_items_failed_id_extraction" when an entire
+ // page yields zero stored, a per-resource
+ // "primary_key_unresolved" the first time any single item
+ // fails, and the F4b "stored_count_zero_after_extraction"
+ // probe when extraction succeeded but rows still didn't land.
+ stored, extractFailures, err := upsertResourceBatch(db, resource, items)
+ if err != nil {
+ if !humanFriendly {
+ fmt.Fprintln(syncEvents, syncErrorJSON(resource, "", err))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf("upserting batch for %s: %w", resource, err), Duration: time.Since(started)}
+ }
+
+ consumedTotal += len(items)
+ extractFailureTotal += extractFailures
+
+ // When a non-empty page yielded zero stored rows, the API
+ // returned items in a shape we couldn't extract IDs from —
+ // likely scalar IDs (Firebase /topstories.json, GitHub user-
+ // repo lists) where the spec author should declare a hydration
+ // pattern, or an unrecognized primary-key field name.
+ if len(items) > 0 && stored == 0 {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: %s returned %d items but stored 0 — the local store will be empty for this resource. Likely cause: scalar item shape rather than objects with extractable IDs.\n", resource, len(items))
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":0,"reason":"all_items_failed_id_extraction"}`+"\n", resource, len(items))
+ }
+ anomalyEmitted = true
+ } else if extractFailures > 0 && !anomalyEmitted {
+ // Per-item primary-key resolution failure but at least one
+ // item landed — emit one structured warning per resource per
+ // sync run so users see the first occurrence of silent drops
+ // instead of waiting for total failure.
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s had %d item(s) on this page with no extractable primary key — those rows were dropped silently. Annotate the spec with x-resource-id to fix.\n", resource, extractFailures)
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":%d,"count":%d,"reason":"primary_key_unresolved"}`+"\n", resource, len(items), stored, extractFailures)
+ }
+ anomalyEmitted = true
+ }
+
+ totalCount += stored
+ atomic.AddInt64(&progressCount, int64(stored))
+ // Collect seen IDs for flat tenant-scoped reconcile. extractID resolves
+ // the primary key the same way UpsertBatch does, so seenIDs match stored
+ // row IDs. Only non-empty IDs are tracked; reconcile never deletes a row
+ // whose ID was seen this run.
+ if flatReconcilable {
+ for _, it := range items {
+ if o, derr := store.DecodeJSONObject(it); derr == nil {
+ if id := extractID(resource, o); id != "" {
+ seenIDs = append(seenIDs, id)
+ }
+ }
+ }
+ }
+ if resourceSupportsPagination(resource) && nextCursor == "" && pageSize.cursorParam != "offset" && len(items) >= pageSize.limit && pageMayHaveMore(data, responsePathForResource(resource, path)...) {
+ emitSyncMissingPaginationCursorWarning(syncEvents, humanFriendly, resource, "")
+ }
+
+ // Progress reporting (include rate limit info when active)
+ currentRate := c.RateLimit()
+ if humanFriendly {
+ if currentRate > 0 {
+ fmt.Fprintf(os.Stderr, "\r %s: %d synced [%.1f req/s]", resource, atomic.LoadInt64(&progressCount), currentRate)
+ } else {
+ fmt.Fprintf(os.Stderr, "\r %s: %d synced", resource, atomic.LoadInt64(&progressCount))
+ }
+ } else {
+ if currentRate > 0 {
+ fmt.Fprintf(syncEvents, `{"event":"sync_progress","resource":"%s","fetched":%d,"rate_rps":%.1f}`+"\n", resource, atomic.LoadInt64(&progressCount), currentRate)
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"sync_progress","resource":"%s","fetched":%d}`+"\n", resource, atomic.LoadInt64(&progressCount))
+ }
+ }
+
+ pagesFetched++
+
+ // Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
+ // Suppress the cap-hit warning when --latest-only is the cap source:
+ // the template pinned maxPages=1 by user intent, and emitting one
+ // warning per paginated resource would mask real sync_anomaly /
+ // sync_error output in the same stream.
+ if maxPages > 0 && pagesFetched >= maxPages {
+ truncatedByCap := resourceSupportsPagination(resource) && hasMore
+ truncatedByCap = truncatedByCap && len(items) >= pageSize.limit
+ if truncatedByCap {
+ capExitCursor = nextCursor
+ }
+ if truncatedByCap && capExitCursor == "" {
+ if pageSize.cursorType == "offset" {
+ currentOffset, _ := strconv.Atoi(cursor)
+ capExitCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ truncatedByCap = false
+ }
+ }
+ if truncatedByCap && capExitCursor != cursor {
+ if !latestOnly {
+ capExitHit = true
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: reached --max-pages limit (%d pages, %d items)\n", resource, maxPages, totalCount)
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"sync_warning","resource":"%s","reason":"max_pages_cap_hit","message":"reached --max-pages cap of %d; data may be truncated. Re-run with --max-pages 0 (unlimited) or higher to verify."}`+"\n", resource, maxPages)
+ }
+ }
+ }
+ // A cap break ends enumeration before exhausting the partition; mirror
+ // the dependent loop and treat it as incomplete (reconcile SKIPS). Safe
+ // default: when the cap may have truncated, never prune.
+ outcome.reason = "max_pages_cap"
+ break
+ }
+
+ // Sticky-cursor detector: if the API echoes the same next cursor across
+ // consecutive pages without advancing, abort to prevent burning the
+ // --max-pages budget on a non-terminating loop. Checked AFTER the cap
+ // guard so cap-hit takes precedence; checked BEFORE the natural-end
+ // check below because the natural-end check would not catch a sticky
+ // non-empty cursor on its own.
+ if nextCursor != "" && nextCursor == lastNextCursor {
+ // Abnormal: a non-advancing cursor means we cannot prove the partition
+ // was fully enumerated — leave outcome.complete=false (reconcile SKIPS).
+ outcome.reason = "stuck_cursor"
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: API returned the same next cursor across two pages; aborting to prevent budget waste.\n", resource)
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"sync_warning","resource":"%s","reason":"stuck_pagination","message":"API returned the same next cursor across two pages for resource %s; aborting to prevent budget waste."}`+"\n", resource, resource)
+ }
+ break
+ }
+ lastNextCursor = nextCursor
+
+ // Determine if there are more pages.
+ if !resourceSupportsPagination(resource) {
+ outcome.complete = true // resource declares no pagination: one page is the whole set
+ break
+ }
+ if !hasMore || len(items) < pageSize.limit {
+ outcome.complete = true
+ break
+ }
+ if nextCursor == "" {
+ if pageSize.cursorType == "offset" {
+ // Cursor-based APIs return the next cursor in the envelope.
+ // Offset-based APIs carry their pagination position client-side.
+ currentOffset, _ := strconv.Atoi(cursor)
+ nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+ } else {
+ // A cursor-based API reporting has_more without a next cursor
+ // cannot advance safely; stop instead of looping silently.
+ outcome.reason = "cursor_unavailable"
+ break
+ }
+ }
+
+ // Save cursor after each page for resumability
+ if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+ // Non-fatal: log and continue
+ fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+ }
+
+ cursor = nextCursor
+ }
+
+ // Flat tenant-scoped reconcile: prune local rows the API no longer returns
+ // within THIS tenant's partition, gated on a proven-complete sync.
+ // - Unknown tenant (resolveTenantID()=="") ⇒ SKIP, zero deletes. This is
+ // the OPPOSITE of the dependent fan-out fallback (which enumerates
+ // unscoped): a flat delete cannot be safely scoped without a tenant, so
+ // we never delete on unknown.
+ // - Incomplete sync (outcome.complete==false) ⇒ SKIP with the recorded
+ // reason; an abnormal break never proves the partition was enumerated.
+ if prune && flatReconcilable {
+ def := flatReconcileDef(resource)
+ tenantUUID := resolveTenantID()
+ if tenantUUID == "" {
+ fmt.Fprintf(syncEvents, `{"event":"reconcile_skipped","resource":"%s","reason":"unknown-tenant"}`+"\n", resource)
+ } else if outcome.complete {
+ deleted, rerr := db.ReconcilePartition(
+ resource, "$."+def.BodyField, tenantUUID,
+ seenIDs, resource, store.CascadeJunctionsFor(resource),
+ )
+ if rerr != nil {
+ fmt.Fprintf(syncEvents, `{"event":"reconcile_error","resource":"%s","scope":"%s","error":%q}`+"\n", resource, tenantUUID, rerr.Error())
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"reconcile","resource":"%s","scope":"%s","deleted":%d}`+"\n", resource, tenantUUID, deleted)
+ }
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"reconcile_skipped","resource":"%s","scope":"%s","reason":%q}`+"\n", resource, tenantUUID, outcome.reason)
+ }
+ }
+
+ // Final sync state: clear cursor on natural completion, but preserve the
+ // resume cursor when an operator intentionally capped the page budget.
+ finalCursor := ""
+ if capExitHit {
+ finalCursor = capExitCursor
+ }
+ _ = db.SaveSyncState(resource, finalCursor, totalCount)
+
+ // F4b symptom probe: if items were consumed and successfully
+ // extracted (extractFailures < consumed) but nothing landed in
+ // the store, something downstream of extraction silently dropped
+ // rows — FTS5 trigger error, transaction rollback, character
+ // encoding. Emit a sync_anomaly so the symptom is visible the
+ // next time it recurs; the underlying root cause is held out for
+ // controlled repro.
+ if consumedTotal > 0 && totalCount == 0 && extractFailureTotal < consumedTotal {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s consumed %d items, extracted %d primary keys, but stored 0 rows — extraction succeeded yet nothing landed. Investigate FTS triggers / transaction rollback / encoding.\n", resource, consumedTotal, consumedTotal-extractFailureTotal)
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":0,"extract_failures":%d,"reason":"stored_count_zero_after_extraction"}`+"\n", resource, consumedTotal, extractFailureTotal)
+ }
+ }
+
+ if !humanFriendly {
+ fmt.Fprintf(syncEvents, `{"event":"sync_complete","resource":"%s","total":%d,"duration_ms":%d}`+"\n", resource, totalCount, time.Since(started).Milliseconds())
+ }
+
+ if consumedTotal > 0 && totalCount == 0 && extractFailureTotal >= consumedTotal {
+ return syncResult{
+ Resource: resource,
+ Count: 0,
+ Warn: fmt.Errorf("%s consumed %d items but stored 0 because no item had an extractable primary key", resource, consumedTotal),
+ Duration: time.Since(started),
+ }
+ }
+
+ return syncResult{Resource: resource, Count: totalCount, Duration: time.Since(started)}
+}
+
+// paginationDefaults holds the resolved pagination parameter names and page size.
+type paginationDefaults struct {
+ cursorParam string
+ cursorType string // paginator class: "", "cursor", "page_token", "offset", "page"
+ limitParam string
+ limit int
+}
+
+// determinePaginationDefaults returns the pagination parameter names to use.
+// Values are detected from the API spec by the profiler at generation time.
+func determinePaginationDefaults(resource string) paginationDefaults {
+ switch resource {
+ }
+ return paginationDefaults{
+ cursorParam: "after",
+ cursorType: "page",
+ limitParam: "limit",
+ limit: 100,
+ }
+}
+
+func resourceSupportsPagination(resource string) bool {
+ switch resource {
+ }
+ return false
+}
+
+// syncResourceSinceParam returns the query parameter name this resource's
+// list endpoint declares for incremental temporal filtering, or "" when the
+// endpoint declares none. Skipping the param for "" resources avoids
+// validation-error 400s on APIs that reject unknown query keys.
+func syncResourceSinceParam(resource string) string {
+ switch resource {
+ }
+ return ""
+}
+
+func syncResourceSinceParamFormat(resource string) string {
+ switch resource {
+ }
+ return ""
+}
+
+func formatSyncSinceValue(value string, paramFormat string) string {
+ if field, ok := strings.CutPrefix(paramFormat, "odata-conditions:"); ok {
+ field = strings.TrimSpace(field)
+ if field == "" {
+ return value
+ }
+ return fmt.Sprintf("%s > [%s]", field, value)
+ }
+ if strings.EqualFold(paramFormat, "date") {
+ if ts, err := time.Parse(time.RFC3339, value); err == nil {
+ return ts.Format("2006-01-02")
+ }
+ if ts, err := time.Parse(time.RFC3339Nano, value); err == nil {
+ return ts.Format("2006-01-02")
+ }
+ if _, err := time.Parse("2006-01-02", value); err == nil {
+ return value
+ }
+ }
+ return value
+}
+
+// extractPageItems attempts to extract an array of items and pagination cursor from a response.
+// It tries multiple strategies:
+// 1. Direct JSON array
+// 2. Common wrapper keys: "data", "results", "items", "records", "nodes", "entries"
+// 3. JSend-style nested data envelopes: {"data":{"":[...]}}
+// It also extracts the next cursor from common response fields.
+func extractPageItems(data json.RawMessage, cursorParam string, responsePaths ...string) ([]json.RawMessage, string, bool) {
+ // Strategy 1: direct array
+ var items []json.RawMessage
+ if err := json.Unmarshal(data, &items); err == nil {
+ return items, "", false
+ }
+
+ // Strategy 2: object with known wrapper keys
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return nil, "", false
+ }
+
+ for _, responsePath := range responsePaths {
+ pathData, ok := responsePayloadAtPath(data, responsePath)
+ if !ok {
+ continue
+ }
+ if items, ok := extractObjectArray(pathData); ok {
+ nextCursor, hasMore := "", false
+ if parentEnvelope, ok := responsePayloadParentAtPath(data, responsePath); ok {
+ nextCursor, hasMore = extractPaginationFromEnvelope(parentEnvelope, cursorParam)
+ }
+ outerCursor, outerHasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ if nextCursor == "" {
+ nextCursor = outerCursor
+ }
+ hasMore = hasMore || outerHasMore
+ return items, nextCursor, hasMore
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(pathData, &inner) == nil {
+ if items, ok := extractItemsFromEnvelope(inner); ok {
+ nextCursor, hasMore := extractPaginationFromEnvelope(inner, cursorParam)
+ outerCursor, outerHasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ if nextCursor == "" {
+ nextCursor = outerCursor
+ }
+ hasMore = hasMore || outerHasMore
+ return items, nextCursor, hasMore
+ }
+ }
+ }
+
+ if items, ok := extractItemsByKnownKeys(envelope); ok {
+ nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ return items, nextCursor, hasMore
+ }
+
+ for _, key := range dataEnvelopeKeys {
+ raw, ok := envelope[key]
+ if !ok {
+ continue
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(raw, &inner) != nil {
+ continue
+ }
+ if items, ok := extractItemsFromEnvelope(inner); ok {
+ nextCursor, hasMore := extractPaginationFromEnvelope(inner, cursorParam)
+ outerCursor, outerHasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ if nextCursor == "" {
+ nextCursor = outerCursor
+ }
+ hasMore = hasMore || outerHasMore
+ return items, nextCursor, hasMore
+ }
+ }
+
+ if raw, ok := envelope["_embedded"]; ok {
+ var embedded map[string]json.RawMessage
+ if json.Unmarshal(raw, &embedded) == nil {
+ if items, ok := extractItemsFromEnvelope(embedded); ok {
+ nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ return items, nextCursor, hasMore
+ }
+ }
+ }
+
+ if items, ok := extractSingleObjectArraySibling(envelope); ok {
+ nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ return items, nextCursor, hasMore
+ }
+
+ return nil, "", false
+}
+
+func extractItemsFromEnvelope(envelope map[string]json.RawMessage) ([]json.RawMessage, bool) {
+ if items, ok := extractItemsByKnownKeys(envelope); ok {
+ return items, true
+ }
+ return extractSingleObjectArraySibling(envelope)
+}
+
+func extractItemsByKnownKeys(envelope map[string]json.RawMessage) ([]json.RawMessage, bool) {
+ for _, key := range pageItemKeys {
+ if raw, ok := envelope[key]; ok {
+ if items, ok := extractObjectArray(raw); ok {
+ return items, true
+ }
+ }
+ }
+ return nil, false
+}
+
+func extractSingleObjectArraySibling(envelope map[string]json.RawMessage) ([]json.RawMessage, bool) {
+ // Fallback: try every key in the envelope. If exactly one maps to a JSON
+ // array with items, use it. This handles APIs that wrap responses with the
+ // resource name alongside arbitrary scalar metadata
+ // (e.g., {"markets": [...], "request_id": "..."}).
+ var arrayItems []json.RawMessage
+ arrayCount := 0
+ for key, raw := range envelope {
+ if pageMetadataArrayKeys[key] {
+ continue
+ }
+ if candidate, ok := extractObjectArray(raw); ok {
+ arrayItems = candidate
+ arrayCount++
+ continue
+ }
+ var rawArray []json.RawMessage
+ if json.Unmarshal(raw, &rawArray) == nil && !isJSONNull(raw) {
+ continue
+ }
+ }
+ if arrayCount == 1 {
+ return arrayItems, true
+ }
+ return nil, false
+}
+
+func extractObjectArray(raw json.RawMessage) ([]json.RawMessage, bool) {
+ var items []json.RawMessage
+ if err := json.Unmarshal(raw, &items); err != nil || len(items) == 0 {
+ return nil, false
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(items[0], &obj); err != nil {
+ return nil, false
+ }
+ return items, true
+}
+
+// isDryRunResponse detects the `{"dry_run": true}` sentinel that
+// client.dryRun returns instead of a real API response. The sync loop
+// uses this to short-circuit before the upsert path, which would
+// otherwise fail with "missing id for " against a sentinel
+// that has no items and no id. The check requires exactly one key
+// (dry_run) so a live API response that happens to include a top-level
+// dry_run field alongside real data isn't misclassified as the
+// sentinel and silently zero out the sync count.
+func isDryRunResponse(data json.RawMessage) bool {
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return false
+ }
+ if len(envelope) != 1 {
+ return false
+ }
+ raw, ok := envelope["dry_run"]
+ if !ok {
+ return false
+ }
+ var v bool
+ return json.Unmarshal(raw, &v) == nil && v
+}
+
+func isEmptyPageResponse(data json.RawMessage, responsePaths ...string) bool {
+ var direct []json.RawMessage
+ if err := json.Unmarshal(data, &direct); err == nil && !isJSONNull(data) {
+ return len(direct) == 0
+ }
+
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return false
+ }
+
+ if isEmptyPageEnvelope(envelope) {
+ return true
+ }
+
+ for _, responsePath := range responsePaths {
+ pathData, ok := responsePayloadAtPath(data, responsePath)
+ if !ok {
+ continue
+ }
+ if isJSONNull(pathData) {
+ if envelopeReportsFailure(envelope) {
+ return true
+ }
+ continue
+ }
+ var direct []json.RawMessage
+ if json.Unmarshal(pathData, &direct) == nil && !isJSONNull(pathData) {
+ return len(direct) == 0
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(pathData, &inner) == nil && isEmptyPageEnvelope(inner) {
+ return true
+ }
+ }
+
+ for _, key := range dataEnvelopeKeys {
+ raw, ok := envelope[key]
+ if !ok {
+ continue
+ }
+ if isJSONNull(raw) {
+ if envelopeReportsFailure(envelope) {
+ return true
+ }
+ continue
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(raw, &inner) == nil && isEmptyPageEnvelope(inner) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func envelopeReportsFailure(envelope map[string]json.RawMessage) bool {
+ for _, key := range []string{"success", "Success"} {
+ if raw, ok := envelope[key]; ok {
+ var success bool
+ if json.Unmarshal(raw, &success) == nil {
+ return !success
+ }
+ }
+ }
+ for _, key := range []string{"status", "Status"} {
+ if raw, ok := envelope[key]; ok {
+ var status string
+ if json.Unmarshal(raw, &status) == nil {
+ switch strings.ToLower(status) {
+ case "error", "fail", "failed":
+ return true
+ }
+ return false
+ }
+ }
+ }
+ return false
+}
+
+func isEmptyPageEnvelope(envelope map[string]json.RawMessage) bool {
+ for _, key := range pageItemKeys {
+ if raw, ok := envelope[key]; ok {
+ var items []json.RawMessage
+ if err := json.Unmarshal(raw, &items); err == nil && !isJSONNull(raw) {
+ if len(items) > 0 {
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(items[0], &obj); err != nil {
+ continue
+ }
+ }
+ return len(items) == 0
+ }
+ }
+ }
+ if hasExactlyOneNullArrayWithZeroCount(envelope) {
+ return true
+ }
+ return hasExactlyOneEmptyArray(envelope)
+}
+
+func hasExactlyOneNullArrayWithZeroCount(envelope map[string]json.RawMessage) bool {
+ nullArrayCount := 0
+ hasZeroCount := false
+ for key, raw := range envelope {
+ if isZeroCountField(key, raw) {
+ hasZeroCount = true
+ continue
+ }
+ if isJSONNull(raw) && isNullPageItemCandidateKey(key) {
+ nullArrayCount++
+ continue
+ }
+ if pageEnvelopeMetadataKeys[key] {
+ continue
+ }
+ return false
+ }
+ return nullArrayCount == 1 && hasZeroCount
+}
+
+func isZeroCountField(key string, raw json.RawMessage) bool {
+ switch key {
+ case "total", "Total", "count", "Count", "total_count", "totalCount", "TotalCount":
+ default:
+ return false
+ }
+ // A JSON null count is not a numeric zero-count signal: json.Unmarshal of
+ // "null" into a float64 is a no-op (leaves n at 0, returns nil), so without
+ // this guard a null count would falsely qualify as zero — masking a
+ // possibly-malformed {"items":null,"total":null} as an empty page.
+ if isJSONNull(raw) {
+ return false
+ }
+ var n float64
+ return json.Unmarshal(raw, &n) == nil && n == 0
+}
+
+func isNullPageItemCandidateKey(key string) bool {
+ for _, itemKey := range pageItemKeys {
+ if key == itemKey {
+ return true
+ }
+ }
+ return !pageEnvelopeMetadataKeys[key]
+}
+
+func hasExactlyOneEmptyArray(envelope map[string]json.RawMessage) bool {
+ arrayCount := 0
+ for _, raw := range envelope {
+ var candidate []json.RawMessage
+ if err := json.Unmarshal(raw, &candidate); err == nil && !isJSONNull(raw) {
+ // Skip candidate arrays that contain primitives (not objects).
+ if len(candidate) > 0 {
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(candidate[0], &obj); err != nil {
+ continue
+ }
+ }
+ if len(candidate) == 0 {
+ arrayCount++
+ }
+ }
+ }
+ return arrayCount == 1
+}
+
+func isJSONNull(raw json.RawMessage) bool {
+ return strings.TrimSpace(string(raw)) == "null"
+}
+
+func isJSONResponse(data json.RawMessage) bool {
+ var probe any
+ return json.Unmarshal(data, &probe) == nil
+}
+
+// extractPaginationFromEnvelope extracts cursor and has_more from a response envelope.
+func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {
+ var hasMore bool
+
+ nextCursor := nextCursorFromLinks(envelope, cursorParam)
+
+ // Try common cursor field names
+ cursorKeys := []string{
+ "next_cursor", "nextCursor", "next_token", "nextToken", "cursor",
+ "next_page_token", "nextPageToken", "page_token", "after", "end_cursor", "endCursor",
+ }
+ if nextCursor == "" {
+ nextCursor = findCursorInMap(envelope, cursorKeys)
+ }
+
+ // If no top-level cursor was found, look one level deeper into well-known
+ // pagination wrapper objects. Slack returns {"messages":[...],
+ // "response_metadata":{"next_cursor":"..."}}; Pipedrive uses
+ // "additional_data"; MongoDB Atlas uses "pagination"; many APIs use
+ // "meta" or "paging". Purely additive — only
+ // runs when the top-level scan returned empty — and uses the same
+ // cursorKeys set so wrapper contents go through the same name match.
+ if nextCursor == "" {
+ paginationWrapperKeys := []string{"response_metadata", "additional_data", "pagination", "meta", "paging"}
+ for _, wrapperKey := range paginationWrapperKeys {
+ rawWrapper, ok := envelope[wrapperKey]
+ if !ok {
+ continue
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(rawWrapper, &inner) != nil {
+ continue
+ }
+ if c := findCursorInMap(inner, cursorKeys); c != "" {
+ nextCursor = c
+ break
+ }
+ }
+ }
+
+ if nextCursor == "" {
+ nextCursor = nextCursorFromTopLevelURL(envelope, cursorParam)
+ }
+
+ // Try common has_more field names
+ hasMoreKeys := []string{"has_more", "hasMore", "has_next", "hasNext", "next_page"}
+ for _, key := range hasMoreKeys {
+ if raw, ok := envelope[key]; ok {
+ if err := json.Unmarshal(raw, &hasMore); err == nil {
+ break
+ }
+ }
+ }
+
+ // If we found a cursor, assume there are more pages even without explicit has_more
+ if nextCursor != "" && !hasMore {
+ hasMore = true
+ }
+
+ return nextCursor, hasMore
+}
+
+func pageAllowsPageIntFallback(data json.RawMessage) bool {
+ return pageMayHaveMore(data)
+}
+
+func pageMayHaveMore(data json.RawMessage, responsePaths ...string) bool {
+ hasMore, parsed := pageExplicitHasMore(data, responsePaths...)
+ return !parsed || hasMore
+}
+
+func pageExplicitHasMore(data json.RawMessage, responsePaths ...string) (bool, bool) {
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return false, false
+ }
+ if hasMore, parsed := envelopeExplicitHasMore(envelope); parsed {
+ return hasMore, true
+ }
+ for _, responsePath := range responsePaths {
+ pathData, ok := responsePayloadAtPath(data, responsePath)
+ if !ok {
+ continue
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(pathData, &inner) == nil {
+ if hasMore, parsed := envelopeExplicitHasMore(inner); parsed {
+ return hasMore, true
+ }
+ }
+ }
+ for _, key := range dataEnvelopeKeys {
+ raw, ok := envelope[key]
+ if !ok {
+ continue
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(raw, &inner) == nil {
+ if hasMore, parsed := envelopeExplicitHasMore(inner); parsed {
+ return hasMore, true
+ }
+ }
+ }
+ return false, false
+}
+
+func envelopeExplicitHasMore(envelope map[string]json.RawMessage) (bool, bool) {
+ for _, key := range []string{"has_more", "hasMore", "has_next", "hasNext", "next_page"} {
+ raw, ok := envelope[key]
+ if !ok {
+ continue
+ }
+ var hasMore bool
+ if json.Unmarshal(raw, &hasMore) == nil {
+ return hasMore, true
+ }
+ }
+ return false, false
+}
+
+// nextCursorFromLinks extracts pagination cursors from JSON:API
+// {"links":{"next":"https://example.com/items?page[cursor]=..."}} and HAL
+// {"_links":{"next":{"href":"https://example.com/items?cursor=..."}}}.
+func nextCursorFromLinks(envelope map[string]json.RawMessage, cursorParam string) string {
+ for _, key := range []string{"links", "_links"} {
+ rawLinks, ok := envelope[key]
+ if !ok {
+ continue
+ }
+ var links map[string]json.RawMessage
+ if json.Unmarshal(rawLinks, &links) != nil {
+ continue
+ }
+ rawNext, ok := links["next"]
+ if !ok {
+ continue
+ }
+ if nextURL := paginationLinkURL(rawNext); nextURL != "" {
+ if cursor := cursorFromNextURL(nextURL, cursorParam); cursor != "" {
+ return cursor
+ }
+ }
+ }
+ return ""
+}
+
+func paginationLinkURL(raw json.RawMessage) string {
+ var nextURL string
+ if json.Unmarshal(raw, &nextURL) == nil {
+ return nextURL
+ }
+ var link map[string]json.RawMessage
+ if json.Unmarshal(raw, &link) != nil {
+ return ""
+ }
+ rawHref, ok := link["href"]
+ if !ok {
+ return ""
+ }
+ if json.Unmarshal(rawHref, &nextURL) != nil {
+ return ""
+ }
+ return nextURL
+}
+
+// nextCursorFromTopLevelURL extracts a cursor from top-level absolute or relative next URLs.
+func nextCursorFromTopLevelURL(envelope map[string]json.RawMessage, cursorParam string) string {
+ for _, key := range []string{"next", "next_url"} {
+ rawNext, ok := envelope[key]
+ if !ok {
+ continue
+ }
+ var nextURL string
+ if json.Unmarshal(rawNext, &nextURL) != nil || nextURL == "" {
+ continue
+ }
+ if isFollowableNextURL(nextURL) {
+ return cursorFromNextURL(nextURL, cursorParam)
+ }
+ }
+ return ""
+}
+
+// isFollowableNextURL reports whether a top-level "next" string is a URL we can
+// pull a cursor query param from: an absolute http(s) URL, a root-relative path,
+// or any value carrying a query string. Bare opaque cursor tokens (no query)
+// are rejected so they aren't mis-parsed.
+func isFollowableNextURL(nextURL string) bool {
+ lower := strings.ToLower(nextURL)
+ return strings.HasPrefix(lower, "http") ||
+ strings.HasPrefix(nextURL, "/") ||
+ strings.Contains(nextURL, "?")
+}
+
+func cursorFromNextURL(nextURL string, cursorParam string) string {
+ cursorKeys := []string{cursorParam}
+ if cursorParam != "page[cursor]" {
+ cursorKeys = append(cursorKeys, "page[cursor]")
+ }
+ if cursorParam != "cursor" {
+ cursorKeys = append(cursorKeys, "cursor")
+ }
+ if cursorParam != "after" {
+ cursorKeys = append(cursorKeys, "after")
+ }
+
+ parsed, err := url.Parse(nextURL)
+ if err != nil {
+ return ""
+ }
+ values := parsed.Query()
+ for _, key := range cursorKeys {
+ if key == "" {
+ continue
+ }
+ if cursor := values.Get(key); cursor != "" {
+ return cursor
+ }
+ }
+ return ""
+}
+
+// findCursorInMap returns the first non-empty string-typed value in m
+// whose key matches one of cursorKeys. Used by extractPaginationFromEnvelope
+// to scan both the top-level envelope and well-known wrapper objects with
+// the same name-match rules — extracted so the two scans can't drift.
+func findCursorInMap(m map[string]json.RawMessage, cursorKeys []string) string {
+ for _, key := range cursorKeys {
+ raw, ok := m[key]
+ if !ok {
+ continue
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+ return s
+ }
+ }
+ return ""
+}
+
+func emitSyncMissingPaginationCursorWarning(syncEvents io.Writer, humanFriendly bool, resource, parent string) {
+ if humanFriendly {
+ if parent != "" {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s returned a full page for parent %s without a next cursor; data may be truncated.\n", resource, parent)
+ return
+ }
+ fmt.Fprintf(os.Stderr, "\nwarning: %s returned a full page without a next cursor; data may be truncated.\n", resource)
+ return
+ }
+ if parent != "" {
+ fmt.Fprintf(syncEvents, `{"event":"sync_warning","resource":"%s","parent":"%s","reason":"pagination_cursor_missing","message":"API returned a full page without a usable next cursor; data may be truncated."}`+"\n", resource, parent)
+ return
+ }
+ fmt.Fprintf(syncEvents, `{"event":"sync_warning","resource":"%s","reason":"pagination_cursor_missing","message":"API returned a full page without a usable next cursor; data may be truncated."}`+"\n", resource)
+}
+
+type discriminatorDispatch struct {
+ Field string
+ Values map[string]string
+}
+
+var discriminatorDispatchers = map[string]discriminatorDispatch{}
+
+func upsertResourceBatch(db *store.Store, resource string, items []json.RawMessage) (int, int, error) {
+ if _, ok := discriminatorDispatchers[resource]; !ok {
+ return db.UpsertBatch(resource, items)
+ }
+
+ grouped := map[string][]json.RawMessage{}
+ order := []string{}
+ for _, item := range items {
+ target := resource
+ if obj, err := store.DecodeJSONObject(item); err == nil {
+ target = resolveDiscriminatedResource(resource, obj)
+ }
+ if _, ok := grouped[target]; !ok {
+ order = append(order, target)
+ }
+ grouped[target] = append(grouped[target], item)
+ }
+
+ var stored, extractFailures int
+ for _, target := range order {
+ targetStored, targetExtractFailures, err := db.UpsertBatch(target, grouped[target])
+ if err != nil {
+ return stored, extractFailures + targetExtractFailures, err
+ }
+ stored += targetStored
+ extractFailures += targetExtractFailures
+ }
+ return stored, extractFailures, nil
+}
+
+func resolveDiscriminatedResource(resource string, obj map[string]any) string {
+ dispatcher, ok := discriminatorDispatchers[resource]
+ if !ok || dispatcher.Field == "" {
+ return resource
+ }
+ value := store.LookupFieldValue(obj, dispatcher.Field)
+ if value == nil {
+ return resource
+ }
+ if target, ok := dispatcher.Values[fmt.Sprintf("%v", value)]; ok && target != "" {
+ return target
+ }
+ return resource
+}
+
+// upsertSingleObject stores a non-array API response as a single record.
+func upsertSingleObject(db *store.Store, resource string, data json.RawMessage) error {
+ obj, err := store.DecodeJSONObject(data)
+ if err != nil {
+ // Not a JSON object either - store raw under resource name
+ return db.Upsert(resource, resource, data)
+ }
+
+ resource = resolveDiscriminatedResource(resource, obj)
+
+ id := extractID(resource, obj)
+ if id == "" {
+ id = resource
+ }
+
+ switch resource {
+ case "account":
+ return db.UpsertAccount(data)
+ case "categories":
+ return db.UpsertCategories(data)
+ case "system":
+ return db.UpsertSystem(data)
+ case "taskers":
+ return db.UpsertTaskers(data)
+ default:
+ return db.Upsert(resource, id, data)
+ }
+}
+
+// parseSinceDuration converts human-friendly duration strings into a time.Time.
+// Supported formats: "7d" (days), "24h" (hours), "30m" (minutes), "1w" (weeks).
+func parseSinceDuration(s string) (time.Time, error) {
+ re := regexp.MustCompile(`^(\d+)([dhwm])$`)
+ matches := re.FindStringSubmatch(strings.TrimSpace(s))
+ if matches == nil {
+ return time.Time{}, fmt.Errorf("expected format like 7d, 24h, 1w, or 30m")
+ }
+
+ n, err := strconv.Atoi(matches[1])
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ now := time.Now()
+ switch matches[2] {
+ case "d":
+ return now.Add(-time.Duration(n) * 24 * time.Hour), nil
+ case "h":
+ return now.Add(-time.Duration(n) * time.Hour), nil
+ case "w":
+ return now.Add(-time.Duration(n) * 7 * 24 * time.Hour), nil
+ case "m":
+ return now.Add(-time.Duration(n) * time.Minute), nil
+ default:
+ return time.Time{}, fmt.Errorf("unknown unit %q", matches[2])
+ }
+}
+
+func defaultSyncResources() []string {
+ return []string{
+ "categories",
+ "taskers",
+ "taskers-favorite",
+ "taskers-tasker-suggestions",
+ }
+}
+
+// knownSyncResourceNames returns every resource name sync will accept —
+// flat resources plus any parent-child dependents. Used by --resource-param
+// validation to reject misspellings before they become silent no-ops.
+func knownSyncResourceNames() []string {
+ names := []string{
+ "categories",
+ "taskers",
+ "taskers-favorite",
+ "taskers-tasker-suggestions",
+ }
+ return names
+}
+
+func describeFailedResources(count int, resources []string) string {
+ return describeResourceFailure(count, "resource(s)", resources)
+}
+
+func describeCriticalFailedResources(count int, resources []string) string {
+ return describeResourceFailure(count, "critical resource(s)", resources)
+}
+
+func describeResourceFailure(count int, label string, resources []string) string {
+ if len(resources) == 0 {
+ return fmt.Sprintf("%d %s failed to sync", count, label)
+ }
+ names := append([]string(nil), resources...)
+ sort.Strings(names)
+ return fmt.Sprintf("%d %s failed to sync: %s", count, label, strings.Join(names, ", "))
+}
+
+// syncResourcePath maps resource names to their actual API endpoint paths.
+// For REST APIs this is typically "/". For non-REST APIs (e.g., Steam)
+// this preserves the actual endpoint path like "/ISteamApps/GetAppList/v2".
+func syncResourcePath(resource string) (string, error) {
+ paths := map[string]string{
+ "categories": "/api/v3/web-client/metro_task_template.json",
+ "taskers": "/api/v3/poster_rabbit_relationships/past",
+ "taskers-favorite": "/api/v3/poster_rabbit_relationships/favorite",
+ "taskers-tasker-suggestions": "/api/v3/web-client/dashboard/tasker_suggestions",
+ }
+ if p, ok := paths[resource]; ok {
+ return p, nil
+ }
+ return "", fmt.Errorf("unknown sync resource %q", resource)
+}
+
+// resourceIDFieldOverrides projects per-resource IDField (set by the profiler
+// from x-resource-id or the response-schema fallback chain) into a runtime
+// lookup map. extractID consults this first so the templated path wins over
+// the generic fallback list; the generic list applies only when the override
+// is empty or the override field is absent on a given item.
+//
+// Includes both flat resources and dependent (parent-child) resources so
+// annotations on a child path-item are honored at runtime, not just on
+// flat paths.
+var resourceIDFieldOverrides = map[string]string{}
+
+// partitionOutcome tracks whether a sync loop (flat tenant-scoped OR dependent
+// per-parent) enumerated its partition completely. complete is set ONLY at
+// proven natural ends; any abnormal break records a reason and leaves
+// complete=false so the gated reconcile SKIPS. scopeVal carries the dependent
+// loop's parent scope value (unused by the flat path).
+type partitionOutcome struct {
+ complete bool
+ reason string
+ scopeVal string
+}
+
+// flatReconcileModes maps a flat resource to its reconcile mode classification
+// (from SyncableResource.ReconcileMode, set by the profiler when a flat resource
+// carries a tenant scope column AND an extractable IDField AND no discriminator).
+// Only "flat" resources are emitted; resourceReconcileMode returns "" for any
+// resource absent here, which is all the flat reconcile gate checks for.
+var flatReconcileModes = map[string]string{}
+
+// resourceReconcileMode returns the flat reconcile classification for a resource,
+// or "" when it is not a flat-reconcilable resource.
+func resourceReconcileMode(resource string) string {
+ return flatReconcileModes[resource]
+}
+
+// flatReconcileDefT carries the per-resource metadata the flat reconcile call
+// site needs. BodyField is the JSON body key (== TenantScopeColumn; the column
+// name and the body key coincide) used to scope ReconcilePartition's
+// json_extract($.) partition filter to the active tenant.
+type flatReconcileDefT struct {
+ BodyField string
+}
+
+// flatReconcileDefs maps each flat-reconcilable resource to its tenant body
+// field. Sourced from SyncableResource.TenantScopeColumn for ReconcileMode=="flat".
+var flatReconcileDefs = map[string]flatReconcileDefT{}
+
+// flatReconcileDef returns the flat reconcile metadata for a resource (zero
+// value when absent; the call site only reaches it for flatReconcilable resources).
+func flatReconcileDef(resource string) flatReconcileDefT {
+ return flatReconcileDefs[resource]
+}
+
+// resolveTenantID returns the active tenant's stable ID for tenant-scoped
+// enumeration and reconcile, or "" = unknown. The generated default cannot
+// resolve (a printed CLI has no tenant registry). A novel override reassigns
+// this with a real resolver in RunE (after flag parsing). No-arg by design: it
+// must not reference any novel (hand-patched) type, so the override owns all
+// config/slug access internally. Consumers branch on "" with OPPOSITE fallbacks:
+// fan-out falls back to UNSCOPED enumeration; flat reconcile SKIPS (never
+// deletes).
+var resolveTenantID = func() string { return "" }
+
+// parentTenantScopeColumns maps a dependent-parent table to its tenant
+// discriminator column (from x-pp-tenant-scope-column), used to scope dependent
+// fan-out. Empty when no parent is annotated.
+var parentTenantScopeColumns = map[string]string{}
+
+// genericIDFieldFallbacks is the runtime safety net for resources that did
+// NOT receive a templated IDField. API-specific names belong in spec
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "api_id", "name", "slug", "key", "code"}
+
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+ "data", "results", "items", "records", "nodes", "entries", "features",
+ "Data", "Results", "Items", "Records", "Nodes", "Entries", "Features",
+}
+
+var dataEnvelopeKeys = []string{"data", "Data", "result", "Result"}
+
+func responsePathForResource(resource, path string) []string {
+ switch resource + "\x00" + path {
+ case "categories\x00/api/v3/web-client/metro_task_template.json":
+ return []string{"initial_task_templates"}
+ case "taskers\x00/api/v3/poster_rabbit_relationships/favorite":
+ return []string{"items"}
+ case "taskers\x00/api/v3/poster_rabbit_relationships/past":
+ return []string{"items"}
+ case "taskers\x00/api/v3/web-client/dashboard/tasker_suggestions":
+ return []string{"items"}
+ }
+ return nil
+}
+
+var pageMetadataArrayKeys = map[string]bool{
+ "errors": true, "Errors": true,
+ "warnings": true, "Warnings": true,
+}
+
+var pageEnvelopeMetadataKeys = map[string]bool{
+ // list wrappers themselves
+ "data": true, "results": true, "items": true,
+ "Data": true, "Results": true, "Items": true,
+ // pagination cursors / tokens
+ "next_cursor": true, "nextCursor": true, "NextCursor": true,
+ "next_token": true, "nextToken": true, "NextToken": true,
+ "next_page_token": true, "nextPageToken": true, "NextPageToken": true,
+ "page_token": true, "pageToken": true, "PageToken": true,
+ "end_cursor": true, "endCursor": true, "EndCursor": true,
+ "start_cursor": true, "startCursor": true, "StartCursor": true,
+ "cursor": true, "Cursor": true, "after": true, "After": true, "before": true, "Before": true,
+ // has-more flags and page numbers
+ "has_more": true, "hasMore": true, "HasMore": true,
+ "has_next": true, "hasNext": true, "HasNext": true,
+ "next_page": true, "nextPage": true, "NextPage": true,
+ "previous_page": true, "previousPage": true, "PreviousPage": true,
+ "page": true, "Page": true, "page_size": true, "pageSize": true, "PageSize": true,
+ "per_page": true, "perPage": true, "PerPage": true,
+ // counts / totals
+ "total": true, "Total": true, "count": true, "Count": true, "size": true, "Size": true,
+ "total_count": true, "totalCount": true, "TotalCount": true,
+ // JSend / common status envelopes
+ "success": true, "status": true, "message": true, "error": true, "errors": true,
+ "warnings": true, "Warnings": true, "ok": true, "Ok": true,
+ // wrapper objects
+ "links": true, "meta": true, "pagination": true,
+ "response_metadata": true, "paging": true,
+ // links shape
+ "next": true, "prev": true, "previous": true, "first": true, "last": true,
+}
+
+// criticalResources is the template-time projection of per-resource Critical
+// (set by the profiler from the spec's path-item x-critical extension). It
+// is consulted at error-aggregation time so a non-critical failure can be
+// downgraded to a sync_warning + exit 0 unless --strict was passed.
+//
+// Includes both flat resources and dependent (parent-child) resources so a
+// failed child sync flagged x-critical: true exits non-zero just like a
+// flat-resource critical failure.
+var criticalResources = map[string]bool{}
+
+// extractID resolves an item's primary-key field. It consults the
+// per-resource templated override first; on miss, it falls through to the
+// generic fallback list. resource may be empty for callers that don't have
+// a resource context (only the generic list applies in that case).
+//
+// Field lookups go through store.LookupFieldValue so snake_case overrides
+// match camelCase JSON renderings. UpsertBatch resolves fields the same
+// way — divergence between the two paths produces silent drops on
+// heterogeneous payloads.
+func extractID(resource string, obj map[string]any) string {
+ if override, ok := resourceIDFieldOverrides[resource]; ok && override != "" {
+ if v := store.LookupFieldValue(obj, override); v != nil {
+ s := store.ResourceIDString(v)
+ if s != "" && s != "" {
+ return s
+ }
+ }
+ }
+ for _, key := range genericIDFieldFallbacks {
+ if v := store.LookupFieldValue(obj, key); v != nil {
+ s := store.ResourceIDString(v)
+ if s != "" && s != "" {
+ return s
+ }
+ }
+ }
+ if s := suffixIDFieldFallback(resource, obj); s != "" {
+ return s
+ }
+ return ""
+}
+
+// suffixIDFieldFallback resolves an id-less resource that keys on its own
+// "_code" / "_id" / "_key" / "_slug" field (e.g. the
+// "currencies" resource keying on "currency_code" — see #2327). It is scoped to
+// the resource's OWN name so a foreign key like account_id/parent_id is never
+// promoted to the primary key, and it uses direct map lookups in a fixed suffix
+// order so the chosen id is deterministic.
+func suffixIDFieldFallback(resourceType string, obj map[string]any) string {
+ for _, base := range resourceIDBaseNames(resourceType) {
+ for _, suffix := range []string{"_id", "_code", "_key", "_slug"} {
+ if v, ok := obj[base+suffix]; ok {
+ if s := scalarIDString(v); s != "" && s != "" {
+ return s
+ }
+ }
+ }
+ camelBase := lowerCamelResourceIDBase(base)
+ for _, suffix := range []string{"Id", "Code", "Key", "Slug"} {
+ if v, ok := obj[camelBase+suffix]; ok {
+ if s := scalarIDString(v); s != "" && s != "" {
+ return s
+ }
+ }
+ }
+ }
+ return ""
+}
+
+// resourceIDBaseNames returns lowercase candidate singular/plural stems of a
+// resource name to build "_id"-style key probes from (e.g. "currencies"
+// -> ["currencies","currency"]). OpenAPI-/path-derived names can carry a
+// leading verb token ("get-currencies"), so the same probes are also attempted
+// on the de-verbed stem. Minimal English depluralization; the raw name is
+// always included so already-singular names work too.
+func resourceIDBaseNames(resourceType string) []string {
+ r := strings.ToLower(strings.TrimSpace(resourceType))
+ if r == "" {
+ return nil
+ }
+ stems := []string{r}
+ if d := stripLeadingResourceVerb(r); d != "" && d != r {
+ stems = append(stems, d)
+ }
+ var bases []string
+ seen := map[string]bool{}
+ add := func(s string) {
+ if s != "" && !seen[s] {
+ seen[s] = true
+ bases = append(bases, s)
+ }
+ }
+ for _, stem := range stems {
+ add(stem)
+ add(depluralizeResourceStem(stem))
+ }
+ return bases
+}
+
+func stripLeadingResourceVerb(r string) string {
+ for _, verb := range []string{"get", "list", "fetch", "find", "retrieve", "read", "show", "all"} {
+ for _, sep := range []string{"-", "_"} {
+ prefix := verb + sep
+ if strings.HasPrefix(r, prefix) && len(r) > len(prefix) {
+ return r[len(prefix):]
+ }
+ }
+ }
+ return ""
+}
+
+func depluralizeResourceStem(r string) string {
+ switch {
+ case strings.HasSuffix(r, "ies") && len(r) > 3:
+ return strings.TrimSuffix(r, "ies") + "y" // currencies -> currency
+ // Plurals formed by adding "es" to a base ending in s/x/z/ch/sh. The
+ // double-s "sses" guard (not bare "ses") keeps soft-e plurals — where the
+ // singular already ends in a silent "e" (cases, databases, licenses,
+ // purchases) — out of this branch; they fall through to the "-s" case below
+ // (cases -> case, not cas). Trade-off: a genuine "-es" plural of an s-ending
+ // singular (buses, statuses) depluralizes imperfectly, but those are rare as
+ // resource names and this stem only feeds best-effort id-field probing.
+ case strings.HasSuffix(r, "sses") || strings.HasSuffix(r, "xes") ||
+ strings.HasSuffix(r, "zes") || strings.HasSuffix(r, "ches") ||
+ strings.HasSuffix(r, "shes"):
+ return strings.TrimSuffix(r, "es") // classes -> class, boxes -> box, dishes -> dish
+ case strings.HasSuffix(r, "s") && !strings.HasSuffix(r, "ss") && len(r) > 1:
+ return strings.TrimSuffix(r, "s") // languages -> language, cases -> case
+ }
+ return r
+}
+
+func lowerCamelResourceIDBase(base string) string {
+ parts := strings.FieldsFunc(base, func(r rune) bool {
+ return r == '_' || r == '-'
+ })
+ if len(parts) == 0 {
+ return base
+ }
+ for i := range parts {
+ if i == 0 {
+ parts[i] = strings.ToLower(parts[i])
+ continue
+ }
+ parts[i] = strings.ToUpper(parts[i][:1]) + strings.ToLower(parts[i][1:])
+ }
+ return strings.Join(parts, "")
+}
+
+func scalarIDString(value any) string {
+ switch value.(type) {
+ case string, bool, int, int8, int16, int32, int64,
+ uint, uint8, uint16, uint32, uint64,
+ float32, float64, json.Number, []byte:
+ return store.ResourceIDString(value)
+ default:
+ return ""
+ }
+}
+
+// lookupRefreshEntityFields names the generic identity-bearing JSON
+// fields the lookup-refresh scanner reads from synced resource rows.
+// Deliberately spec-independent: identity fields converge on these
+// names across APIs, and the recorded-miss filter plus per-kind caps
+// keep any over-extraction from mattering.
+var lookupRefreshEntityFields = []string{"name", "title", "display_name", "full_name", "short_name", "label"}
+
+// refreshLookupsFromSyncedStore runs the post-sync lookup-refresh
+// scanner: entities that recall recorded as lookup misses and that now
+// appear in synced resources become entity_lookups rows under
+// source='synced', resolvable on the next recall without a reprint.
+// Failures degrade to a warning — a refresh problem must never turn a
+// successful sync into a failed command.
+func refreshLookupsFromSyncedStore(ctx context.Context, db *sql.DB, syncEvents io.Writer) {
+ learnCfg := newLearnConfig()
+ res, err := lookups.RefreshFromSynced(ctx, db, lookups.RefreshOpts{
+ Extract: func(_ string, data []byte) []string {
+ return learn.ResourceEntitiesFromJSON(data, lookupRefreshEntityFields, learnCfg)
+ },
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: lookup refresh skipped: %v\n", err)
+ return
+ }
+ if res.Inserted == 0 {
+ return
+ }
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "Lookup refresh: %d entity lookup(s) derived from synced data\n", res.Inserted)
+ } else {
+ fmt.Fprintf(syncEvents, `{"event":"lookup_refresh","inserted":%d,"scanned":%d}`+"\n", res.Inserted, res.Scanned)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/sync_hint.go b/library/productivity/human-goat/internal/cli/sync_hint.go
new file mode 100644
index 0000000000..1b7eda81c1
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/sync_hint.go
@@ -0,0 +1,123 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+ "github.com/spf13/cobra"
+)
+
+type syncHintState struct {
+ hasState bool
+ lastSynced time.Time
+}
+
+func maybeEmitSyncHints(cmd *cobra.Command, db *store.Store, resourceType string, maxAge time.Duration) {
+ if cmd == nil {
+ return
+ }
+ emitSyncHints(cmd.ErrOrStderr(), db, resourceType, maxAge)
+}
+
+func emitSyncHints(w io.Writer, db *store.Store, resourceType string, maxAge time.Duration) {
+ state, err := readSyncHintState(db, resourceType)
+ if err != nil || w == nil {
+ return
+ }
+ if !state.hasState {
+ fmt.Fprintf(w, "hint: local store has not been synced yet. Run 'human-goat-pp-cli sync' before trusting local results.\n")
+ return
+ }
+ if maxAge <= 0 {
+ return
+ }
+ age := time.Since(state.lastSynced)
+ if age <= maxAge {
+ return
+ }
+ fmt.Fprintf(w, "hint: local store data is %s old, older than --max-age=%s. Run 'human-goat-pp-cli sync' to refresh.\n", syncHintRoundAge(age), maxAge)
+}
+
+func hintIfUnsynced(cmd *cobra.Command, db *store.Store, resourceType string) bool {
+ if cmd == nil || db == nil {
+ return false
+ }
+ state, err := readSyncHintState(db, resourceType)
+ if err != nil || state.hasState {
+ return false
+ }
+ fmt.Fprintf(cmd.ErrOrStderr(), "hint: local store has not been synced yet. Run 'human-goat-pp-cli sync' before trusting local results.\n")
+ return true
+}
+
+func hintIfStale(cmd *cobra.Command, db *store.Store, resourceType string, maxAge time.Duration) bool {
+ if cmd == nil || db == nil || maxAge <= 0 {
+ return false
+ }
+ state, err := readSyncHintState(db, resourceType)
+ if err != nil || !state.hasState {
+ return false
+ }
+ age := time.Since(state.lastSynced)
+ if age <= maxAge {
+ return false
+ }
+ fmt.Fprintf(cmd.ErrOrStderr(), "hint: local store data is %s old, older than --max-age=%s. Run 'human-goat-pp-cli sync' to refresh.\n", syncHintRoundAge(age), maxAge)
+ return true
+}
+
+func readSyncHintState(db *store.Store, resourceType string) (syncHintState, error) {
+ if db == nil {
+ return syncHintState{}, nil
+ }
+ query := `SELECT last_synced_at FROM sync_state`
+ args := []any{}
+ if strings.TrimSpace(resourceType) != "" {
+ query += ` WHERE resource_type = ?`
+ args = append(args, resourceType)
+ } else {
+ query += ` WHERE last_synced_at IS NOT NULL`
+ }
+ query += ` ORDER BY last_synced_at ASC LIMIT 1`
+
+ var lastSynced sql.NullTime
+ err := db.DB().QueryRow(query, args...).Scan(&lastSynced)
+ if err == nil {
+ if !lastSynced.Valid {
+ return syncHintState{}, nil
+ }
+ return syncHintState{
+ hasState: true,
+ lastSynced: lastSynced.Time,
+ }, nil
+ }
+ if errors.Is(err, sql.ErrNoRows) || syncHintMissingTable(err) {
+ return syncHintState{}, nil
+ }
+ return syncHintState{}, err
+}
+
+func syncHintMissingTable(err error) bool {
+ for err != nil {
+ if strings.Contains(err.Error(), "no such table") {
+ return true
+ }
+ err = errors.Unwrap(err)
+ }
+ return false
+}
+
+func syncHintRoundAge(age time.Duration) time.Duration {
+ if age < time.Minute {
+ return age.Round(time.Second)
+ }
+ return age.Round(time.Minute)
+}
diff --git a/library/productivity/human-goat/internal/cli/sync_hint_test.go b/library/productivity/human-goat/internal/cli/sync_hint_test.go
new file mode 100644
index 0000000000..58656d7790
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/sync_hint_test.go
@@ -0,0 +1,175 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+ "github.com/spf13/cobra"
+)
+
+func newSyncHintTestStore(t *testing.T) *store.Store {
+ t.Helper()
+ db, err := store.OpenWithContext(context.Background(), filepath.Join(t.TempDir(), "data.db"))
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ return db
+}
+
+func newSyncHintTestCmd() (*cobra.Command, *bytes.Buffer) {
+ var stderr bytes.Buffer
+ cmd := &cobra.Command{Use: "human-goat-pp-cli"}
+ cmd.SetErr(&stderr)
+ return cmd, &stderr
+}
+
+func TestHintIfUnsynced_EmptySyncStateWritesHintToStderr(t *testing.T) {
+ db := newSyncHintTestStore(t)
+ cmd, stderr := newSyncHintTestCmd()
+
+ if !hintIfUnsynced(cmd, db, "") {
+ t.Fatalf("hintIfUnsynced returned false for empty sync_state")
+ }
+ if got := stderr.String(); !strings.Contains(got, "Run 'human-goat-pp-cli sync'") {
+ t.Fatalf("stderr = %q, want sync hint", got)
+ }
+}
+
+func TestHintIfStale_BackdatedSyncStateWritesHintToStderr(t *testing.T) {
+ db := newSyncHintTestStore(t)
+ if _, err := db.DB().Exec(
+ `INSERT INTO sync_state(resource_type, last_synced_at, total_count) VALUES (?, ?, ?)`,
+ "issues", time.Now().Add(-2*time.Hour), 1,
+ ); err != nil {
+ t.Fatalf("seed sync_state: %v", err)
+ }
+ cmd, stderr := newSyncHintTestCmd()
+
+ if hintIfUnsynced(cmd, db, "") {
+ t.Fatalf("hintIfUnsynced returned true after sync_state was seeded")
+ }
+ if !hintIfStale(cmd, db, "", 30*time.Minute) {
+ t.Fatalf("hintIfStale returned false for stale sync_state")
+ }
+ got := stderr.String()
+ if !strings.Contains(got, "older than --max-age=30m0s") || !strings.Contains(got, "Run 'human-goat-pp-cli sync'") {
+ t.Fatalf("stderr = %q, want stale sync hint", got)
+ }
+}
+
+func TestHintIfStale_MaxAgeZeroDisablesHint(t *testing.T) {
+ db := newSyncHintTestStore(t)
+ if _, err := db.DB().Exec(
+ `INSERT INTO sync_state(resource_type, last_synced_at, total_count) VALUES (?, ?, ?)`,
+ "issues", time.Now().Add(-2*time.Hour), 1,
+ ); err != nil {
+ t.Fatalf("seed sync_state: %v", err)
+ }
+ cmd, stderr := newSyncHintTestCmd()
+
+ if hintIfStale(cmd, db, "", 0) {
+ t.Fatalf("hintIfStale returned true when maxAge is zero")
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("stderr = %q, want no hint", stderr.String())
+ }
+}
+
+func TestHintIfUnsynced_NullTimestampWritesHint(t *testing.T) {
+ db := newSyncHintTestStore(t)
+ if _, err := db.DB().Exec(
+ `INSERT INTO sync_state(resource_type, last_synced_at, total_count) VALUES (?, ?, ?)`,
+ "issues", nil, 1,
+ ); err != nil {
+ t.Fatalf("seed sync_state: %v", err)
+ }
+ cmd, stderr := newSyncHintTestCmd()
+
+ if !hintIfUnsynced(cmd, db, "issues") {
+ t.Fatalf("hintIfUnsynced returned false for null last_synced_at")
+ }
+ if got := stderr.String(); !strings.Contains(got, "has not been synced yet") {
+ t.Fatalf("stderr = %q, want unsynced hint", got)
+ }
+}
+
+func TestHintIfStale_AllResourcesIgnoresNullTimestampRows(t *testing.T) {
+ db := newSyncHintTestStore(t)
+ now := time.Now()
+ for _, row := range []struct {
+ resource string
+ syncedAt any
+ }{
+ {"users", nil},
+ {"issues", now.Add(-2 * time.Hour)},
+ } {
+ if _, err := db.DB().Exec(
+ `INSERT INTO sync_state(resource_type, last_synced_at, total_count) VALUES (?, ?, ?)`,
+ row.resource, row.syncedAt, 1,
+ ); err != nil {
+ t.Fatalf("seed %s sync_state: %v", row.resource, err)
+ }
+ }
+
+ cmd, stderr := newSyncHintTestCmd()
+ if hintIfUnsynced(cmd, db, "") {
+ t.Fatalf("hintIfUnsynced returned true when a valid sync timestamp exists")
+ }
+ if !hintIfStale(cmd, db, "", 30*time.Minute) {
+ t.Fatalf("hintIfStale returned false for oldest valid all-resource timestamp")
+ }
+ if got := stderr.String(); !strings.Contains(got, "older than --max-age=30m0s") {
+ t.Fatalf("stderr = %q, want stale hint from valid timestamp", got)
+ }
+}
+
+func TestHintIfStale_ResourceFilterUsesRequestedResource(t *testing.T) {
+ db := newSyncHintTestStore(t)
+ now := time.Now()
+ for _, row := range []struct {
+ resource string
+ syncedAt time.Time
+ }{
+ {"users", now.Add(-5 * time.Minute)},
+ {"issues", now.Add(-2 * time.Hour)},
+ } {
+ if _, err := db.DB().Exec(
+ `INSERT INTO sync_state(resource_type, last_synced_at, total_count) VALUES (?, ?, ?)`,
+ row.resource, row.syncedAt, 1,
+ ); err != nil {
+ t.Fatalf("seed %s sync_state: %v", row.resource, err)
+ }
+ }
+
+ cmd, stderr := newSyncHintTestCmd()
+ if hintIfStale(cmd, db, "users", 30*time.Minute) {
+ t.Fatalf("hintIfStale returned true for fresh users resource")
+ }
+ if stderr.Len() != 0 {
+ t.Fatalf("stderr = %q, want no hint for fresh users resource", stderr.String())
+ }
+
+ if !hintIfStale(cmd, db, "issues", 30*time.Minute) {
+ t.Fatalf("hintIfStale returned false for stale issues resource")
+ }
+ if got := stderr.String(); !strings.Contains(got, "older than --max-age=30m0s") {
+ t.Fatalf("stderr = %q, want stale issues hint", got)
+ }
+
+ cmd, stderr = newSyncHintTestCmd()
+ if !hintIfUnsynced(cmd, db, "comments") {
+ t.Fatalf("hintIfUnsynced returned false for unsynced comments resource")
+ }
+ if got := stderr.String(); !strings.Contains(got, "has not been synced yet") {
+ t.Fatalf("stderr = %q, want unsynced comments hint", got)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/sync_numeric_id_test.go b/library/productivity/human-goat/internal/cli/sync_numeric_id_test.go
new file mode 100644
index 0000000000..3b2b7f7ca3
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/sync_numeric_id_test.go
@@ -0,0 +1,47 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+func TestSyncSingleObject_PreservesLargeIntegerResourceIDs(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ db, err := store.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer db.Close()
+
+ if err := upsertSingleObject(db, "numeric_ids", json.RawMessage(`{"id": 55043301, "name": "large"}`)); err != nil {
+ t.Fatalf("upsertSingleObject: %v", err)
+ }
+
+ var got string
+ if err := db.DB().QueryRow(
+ `SELECT id FROM resources WHERE resource_type = ?`,
+ "numeric_ids",
+ ).Scan(&got); err != nil {
+ t.Fatalf("query resource id: %v", err)
+ }
+ if got != "55043301" {
+ t.Fatalf("resource id = %q, want %q", got, "55043301")
+ }
+
+ var literalMatches int
+ if err := db.DB().QueryRow(
+ `SELECT COUNT(*) FROM resources WHERE resource_type = ? AND id = '55043301'`,
+ "numeric_ids",
+ ).Scan(&literalMatches); err != nil {
+ t.Fatalf("count literal id matches: %v", err)
+ }
+ if literalMatches != 1 {
+ t.Fatalf("literal id matches = %d, want 1", literalMatches)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/system.go b/library/productivity/human-goat/internal/cli/system.go
new file mode 100644
index 0000000000..b822269bd4
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/system.go
@@ -0,0 +1,22 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func newSystemCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "system",
+ Short: "TaskRabbit account bootstrap, metro, and dashboard reachability",
+ Hidden: true,
+ Annotations: map[string]string{"mcp:read-only": "true", "pp:typed-exit-codes": "0,2"},
+ RunE: parentNoSubcommandRunE(flags),
+ }
+
+ cmd.AddCommand(newSystemBootstrapCmd(flags))
+ cmd.AddCommand(newSystemDashboardCountsCmd(flags))
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/system_bootstrap.go b/library/productivity/human-goat/internal/cli/system_bootstrap.go
new file mode 100644
index 0000000000..f74f0e8868
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/system_bootstrap.go
@@ -0,0 +1,78 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newSystemBootstrapCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "bootstrap",
+ Short: "Account bootstrap — metro (id, name, country), payment_method_types, stream_api_key.",
+ Example: " human-goat-pp-cli system bootstrap",
+ Annotations: map[string]string{"pp:endpoint": "system.bootstrap", "pp:method": "GET", "pp:path": "/api/v3/web-client/bootstrap.json", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ path := "/api/v3/web-client/bootstrap.json"
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ params := map[string]string{}
+ data, prov, err := resolveReadWithStrategyAndResponsePath(cmd.Context(), c, flags, "auto", "system", false, path, params, nil, "", cmd.ErrOrStderr())
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_promoted.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested. Explicit format flags (--csv, --quiet,
+ // --plain) opt out of the auto-JSON path so piped consumers that asked for
+ // a non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlagsMeta(cmd.OutOrStdout(), data, flags, map[string]any{"source": "live"})
+ },
+ }
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/system_dashboard_counts.go b/library/productivity/human-goat/internal/cli/system_dashboard_counts.go
new file mode 100644
index 0000000000..28c077b23f
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/system_dashboard_counts.go
@@ -0,0 +1,78 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newSystemDashboardCountsCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "dashboard-counts",
+ Short: "Dashboard tab counts (open tasks, messages, etc.)",
+ Example: " human-goat-pp-cli system dashboard-counts",
+ Annotations: map[string]string{"pp:endpoint": "system.dashboard_counts", "pp:method": "GET", "pp:path": "/api/v3/web-client/dashboard/counts", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ path := "/api/v3/web-client/dashboard/counts"
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ params := map[string]string{}
+ data, prov, err := resolveReadWithStrategyAndResponsePath(cmd.Context(), c, flags, "auto", "system", false, path, params, nil, "", cmd.ErrOrStderr())
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_promoted.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested. Explicit format flags (--csv, --quiet,
+ // --plain) opt out of the auto-JSON path so piped consumers that asked for
+ // a non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlagsMeta(cmd.OutOrStdout(), data, flags, map[string]any{"source": "live"})
+ },
+ }
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/taskers.go b/library/productivity/human-goat/internal/cli/taskers.go
new file mode 100644
index 0000000000..8abb8fbef0
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/taskers.go
@@ -0,0 +1,23 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "github.com/spf13/cobra"
+)
+
+func newTaskersCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "taskers",
+ Short: "Favorite, past, and suggested TaskRabbit Taskers",
+ Hidden: true,
+ Annotations: map[string]string{"mcp:read-only": "true", "pp:typed-exit-codes": "0,2"},
+ RunE: parentNoSubcommandRunE(flags),
+ }
+
+ cmd.AddCommand(newTaskersFavoritesCmd(flags))
+ cmd.AddCommand(newTaskersPastCmd(flags))
+ cmd.AddCommand(newTaskersSuggestionsCmd(flags))
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/taskers_favorites.go b/library/productivity/human-goat/internal/cli/taskers_favorites.go
new file mode 100644
index 0000000000..1ca1b6d0ba
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/taskers_favorites.go
@@ -0,0 +1,83 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newTaskersFavoritesCmd(flags *rootFlags) *cobra.Command {
+ var flagPage string
+ var flagAll bool
+
+ cmd := &cobra.Command{
+ Use: "favorites",
+ Short: "List your favorited Taskers (poster=client, rabbit=tasker)",
+ Example: " human-goat-pp-cli taskers favorites --agent --select items.name,items.all_in_rate,items.rating",
+ Annotations: map[string]string{"pp:endpoint": "taskers.favorites", "pp:method": "GET", "pp:path": "/api/v3/poster_rabbit_relationships/favorite", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ path := "/api/v3/poster_rabbit_relationships/favorite"
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ data, prov, err := resolvePaginatedReadWithStrategy(cmd.Context(), c, flags, "auto", "taskers", path, map[string]string{
+ "page": formatCLIParamValue(flagPage),
+ }, nil, flagAll, "", "page", "", "", "meta.total_pages", cmd.ErrOrStderr())
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_promoted.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested. Explicit format flags (--csv, --quiet,
+ // --plain) opt out of the auto-JSON path so piped consumers that asked for
+ // a non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlagsMeta(cmd.OutOrStdout(), data, flags, map[string]any{"source": "live"})
+ },
+ }
+ cmd.Flags().StringVar(&flagPage, "page", "", "Page number for pagination")
+ cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/taskers_past.go b/library/productivity/human-goat/internal/cli/taskers_past.go
new file mode 100644
index 0000000000..09ada44dac
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/taskers_past.go
@@ -0,0 +1,83 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newTaskersPastCmd(flags *rootFlags) *cobra.Command {
+ var flagPage string
+ var flagAll bool
+
+ cmd := &cobra.Command{
+ Use: "past",
+ Short: "List Taskers you have hired before",
+ Example: " human-goat-pp-cli taskers past",
+ Annotations: map[string]string{"pp:endpoint": "taskers.past", "pp:method": "GET", "pp:path": "/api/v3/poster_rabbit_relationships/past", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ path := "/api/v3/poster_rabbit_relationships/past"
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ data, prov, err := resolvePaginatedReadWithStrategy(cmd.Context(), c, flags, "auto", "taskers", path, map[string]string{
+ "page": formatCLIParamValue(flagPage),
+ }, nil, flagAll, "", "page", "", "", "meta.total_pages", cmd.ErrOrStderr())
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_promoted.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested. Explicit format flags (--csv, --quiet,
+ // --plain) opt out of the auto-JSON path so piped consumers that asked for
+ // a non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlagsMeta(cmd.OutOrStdout(), data, flags, map[string]any{"source": "live"})
+ },
+ }
+ cmd.Flags().StringVar(&flagPage, "page", "", "Page number for pagination")
+ cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/taskers_suggestions.go b/library/productivity/human-goat/internal/cli/taskers_suggestions.go
new file mode 100644
index 0000000000..f9b9fed558
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/taskers_suggestions.go
@@ -0,0 +1,78 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newTaskersSuggestionsCmd(flags *rootFlags) *cobra.Command {
+
+ cmd := &cobra.Command{
+ Use: "suggestions",
+ Short: "Tasker suggestions for the account metro",
+ Example: " human-goat-pp-cli taskers suggestions",
+ Annotations: map[string]string{"pp:endpoint": "taskers.suggestions", "pp:method": "GET", "pp:path": "/api/v3/web-client/dashboard/tasker_suggestions", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ path := "/api/v3/web-client/dashboard/tasker_suggestions"
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ params := map[string]string{}
+ data, prov, err := resolveReadWithStrategyAndResponsePath(cmd.Context(), c, flags, "auto", "taskers", false, path, params, nil, "items", cmd.ErrOrStderr())
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ // Print provenance to stderr for human-facing output only.
+ // Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+ // --select) and piped stdout suppress this line; the JSON envelope
+ // already carries meta.source for those consumers.
+ // SYNC: keep this gate aligned with command_promoted.go.tmpl.
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var countItems []json.RawMessage
+ _ = json.Unmarshal(data, &countItems)
+ printProvenance(cmd, len(countItems), prov)
+ }
+ // For JSON output, wrap with provenance envelope before passing through flags.
+ // --select wins over --compact when both are set; --compact only runs when
+ // no explicit fields were requested. Explicit format flags (--csv, --quiet,
+ // --plain) opt out of the auto-JSON path so piped consumers that asked for
+ // a non-JSON format reach the standard pipeline below.
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+ if wrapErr != nil {
+ return wrapErr
+ }
+ return printOutput(cmd.OutOrStdout(), wrapped, true)
+ }
+ // For all other output modes (table, csv, plain, quiet), use the standard pipeline
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlagsMeta(cmd.OutOrStdout(), data, flags, map[string]any{"source": "live"})
+ },
+ }
+
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/tasks.go b/library/productivity/human-goat/internal/cli/tasks.go
new file mode 100644
index 0000000000..1387da57b1
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/tasks.go
@@ -0,0 +1,151 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// pp:data-source live
+
+package cli
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/taskrabbit"
+
+ "github.com/spf13/cobra"
+)
+
+func newTasksCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "tasks",
+ Short: "List and inspect TaskRabbit bookings",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: parentNoSubcommandRunE(flags),
+ }
+
+ cmd.AddCommand(newTasksListCmd(flags))
+ cmd.AddCommand(newTasksGetCmd(flags))
+ return cmd
+}
+
+func newTasksListCmd(flags *rootFlags) *cobra.Command {
+ var flagPage int
+ var flagPerPage int
+ var flagStatus string
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List your TaskRabbit bookings, filterable by status",
+ Example: " human-goat-pp-cli tasks list --status active --agent",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ fmt.Fprintln(cmd.OutOrStdout(), "would list TaskRabbit tasks")
+ return nil
+ }
+ if flagPage <= 0 {
+ return usageErr(fmt.Errorf("--page must be positive"))
+ }
+ if flagPerPage <= 0 {
+ return usageErr(fmt.Errorf("--per-page must be positive"))
+ }
+
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ tr := taskrabbit.New(c)
+ bookings, err := tr.ListTasks(cmd.Context(), flagPage, flagPerPage, map[string]any{}, "en-US")
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+
+ rows := make([]taskBookingSummary, 0, len(bookings))
+ for _, booking := range bookings {
+ if flagStatus != "" && !strings.EqualFold(booking.Status, flagStatus) {
+ continue
+ }
+ rows = append(rows, taskBookingSummary{
+ ID: booking.ID,
+ Status: booking.Status,
+ })
+ }
+ if flags.asJSON || flags.agent {
+ return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
+ }
+ tableRows := make([][]string, 0, len(rows))
+ for _, row := range rows {
+ tableRows = append(tableRows, []string{row.ID, row.Status})
+ }
+ return flags.printTable(cmd, []string{"ID", "STATUS"}, tableRows)
+ },
+ }
+ cmd.Flags().IntVar(&flagPage, "page", 1, "Page number")
+ cmd.Flags().IntVar(&flagPerPage, "per-page", 20, "Results per page")
+ cmd.Flags().StringVar(&flagStatus, "status", "", "Filter returned bookings by status")
+ return cmd
+}
+
+func newTasksGetCmd(flags *rootFlags) *cobra.Command {
+ var flagPage int
+ var flagPerPage int
+
+ cmd := &cobra.Command{
+ Use: "get ",
+ Short: "Get a TaskRabbit booking from the task list",
+ Example: " human-goat-pp-cli tasks get 35292888 --agent",
+ Annotations: map[string]string{"mcp:read-only": "true", "pp:no-error-path-probe": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && !commandHasChangedFlags(cmd) {
+ return cmd.Help()
+ }
+ id := strings.TrimSpace(strings.Join(args, " "))
+ if dryRunOK(flags) {
+ if id == "" {
+ id = ""
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "would get TaskRabbit task %s\n", id)
+ return nil
+ }
+ if id == "" {
+ return usageErr(fmt.Errorf("missing id"))
+ }
+ if flagPage <= 0 {
+ return usageErr(fmt.Errorf("--page must be positive"))
+ }
+ if flagPerPage <= 0 {
+ return usageErr(fmt.Errorf("--per-page must be positive"))
+ }
+
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ tr := taskrabbit.New(c)
+ bookings, err := tr.ListTasks(cmd.Context(), flagPage, flagPerPage, map[string]any{}, "en-US")
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ for _, booking := range bookings {
+ if booking.ID != id {
+ continue
+ }
+ if flags.asJSON || flags.agent {
+ if len(booking.Raw) > 0 {
+ return printJSONFiltered(cmd.OutOrStdout(), booking.Raw, flags)
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), booking, flags)
+ }
+ return flags.printTable(cmd, []string{"ID", "STATUS"}, [][]string{{booking.ID, booking.Status}})
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "no booking %s in the first %d results; try --per-page higher\n", id, flagPerPage)
+ return nil
+ },
+ }
+ cmd.Flags().IntVar(&flagPage, "page", 1, "Page number")
+ cmd.Flags().IntVar(&flagPerPage, "per-page", 20, "Results per page")
+ return cmd
+}
+
+type taskBookingSummary struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+}
diff --git a/library/productivity/human-goat/internal/cli/teach.go b/library/productivity/human-goat/internal/cli/teach.go
new file mode 100644
index 0000000000..39834915ea
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/teach.go
@@ -0,0 +1,1021 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// teach.go implements the LLM-driven learning surface for the
+// human-goat-pp-cli CLI: `teach` (fire-and-forget, silent on success,
+// safe to background with &), `recall` (pre-discovery short-circuit
+// for known queries), `learnings list` (human inspection),
+// `learnings forget` (human undo), `teach-pattern` (manual pattern
+// install), and `teach-lookup` (manual entity-lookup row install).
+//
+// The commands here delegate to the generator-owned internal/learn
+// package for the data-shape work; this file is the cobra surface
+// that wires flags, validates inputs, and shapes the JSON output the
+// MCP layer reflects to agents.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/lookups"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/patterns"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// noLearnEnvVar is the environment variable that disables the learning
+// loop for a session. Used by deterministic agent flows that don't
+// want a learning row to silently change subsequent query results.
+const noLearnEnvVar = "HUMAN_GOAT_NO_LEARN"
+
+// learningsAuditFileName is the JSONL audit log alongside the DB.
+const learningsAuditFileName = "learnings.jsonl"
+
+// teachErrLogFileName is the plain-text error log for backgrounded
+// teach failures. Distinct from the structured warnings file the
+// learn package maintains via TeachLogPath/AppendTeachLogWarning.
+const teachErrLogFileName = "teach.log"
+
+// noLearnEnabled reports whether the env var is truthy.
+func noLearnEnabled() bool {
+ v := strings.ToLower(strings.TrimSpace(os.Getenv(noLearnEnvVar)))
+ return v == "true" || v == "1" || v == "yes"
+}
+
+// noLearnActive combines the --no-learn flag with the env var so call
+// sites stay declarative.
+func noLearnActive(flags *rootFlags) bool {
+ if flags != nil && flags.noLearn {
+ return true
+ }
+ return noLearnEnabled()
+}
+
+// learningsStateDir returns the directory hosting the audit log and
+// error log. Created on first use with 0o700 so a multi-user machine
+// doesn't accidentally expose one user's learned queries.
+func learningsStateDir() (string, error) {
+ dir, err := cliutil.StateDir()
+ if err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
+
+func learningsAuditPath() (string, error) {
+ dir, err := learningsStateDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, learningsAuditFileName), nil
+}
+
+func teachErrLogPath() (string, error) {
+ dir, err := learningsStateDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, teachErrLogFileName), nil
+}
+
+// writeTeachErrLog appends a single line to teach.log. Best-effort.
+func writeTeachErrLog(line string) {
+ p, err := teachErrLogPath()
+ if err != nil {
+ return
+ }
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return
+ }
+ defer f.Close()
+ ts := time.Now().UTC().Format(time.RFC3339)
+ fmt.Fprintf(f, "%s %s\n", ts, line)
+}
+
+// appendLearningsAudit records one event in the JSONL audit log.
+func appendLearningsAudit(entry map[string]any) error {
+ p, err := learningsAuditPath()
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ entry["ts"] = time.Now().UTC().Format(time.RFC3339)
+ return json.NewEncoder(f).Encode(entry)
+}
+
+// silentCodeErr returns an error that ExitCode honors but that carries
+// no message. Used by the teach command's error path so a backgrounded
+// failure leaks nothing to the parent shell's stderr — the diagnosis
+// lives in teach.log instead.
+func silentCodeErr(code int) error {
+ return &cliError{code: code, err: silentSentinel{}}
+}
+
+type silentSentinel struct{}
+
+func (silentSentinel) Error() string { return "" }
+
+// learnDBPath resolves the SQLite path the learn commands should use:
+// the explicit --db flag wins, otherwise the canonical default.
+func learnDBPath(explicit string) string {
+ if explicit != "" {
+ return explicit
+ }
+ return defaultDBPath("human-goat-pp-cli")
+}
+
+// newTeachCmd builds the `teach` cobra command — the LLM-facing write
+// surface. Silent on success, safe to background, errors only to
+// teach.log. Requires --resource-type so recalls can validate returned
+// IDs against the typed resources table.
+func newTeachCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
+ var query string
+ var resources []string
+ var venueArg string
+ var resourceType string
+ var quiet bool
+ var dbPath string
+ var notes string
+ var noValidate bool
+ // Playbook side -- optional. When any is set, after the resource
+ // learning lands, also upsert a learning_playbooks row keyed on the
+ // query family. Failures here log to teach.log but don't fail the
+ // resource learning (graceful degrade). --playbook-json is the
+ // inline alternative to --playbook-file (MCP-only agents have no
+ // filesystem to stage a file on); the two are mutually exclusive.
+ var playbookFile string
+ var playbookJSONInline string
+ var playbookNotesInline string
+ var playbookNotesFile string
+
+ cmd := &cobra.Command{
+ Use: "teach",
+ Short: "Record a query -> resource mapping for future recall (LLM-fired, silent)",
+ // Bare invocation intentionally exits 2 (silent usage error to
+ // teach.log); declare it so `cli-printing-press verify` scores the
+ // Execute cell honestly instead of masking a FAIL. Writes land only
+ // in the CLI's own local store, so MCP hosts get non-destructive
+ // local-write hints instead of a "could mutate anything" prompt.
+ Annotations: map[string]string{"pp:typed-exit-codes": "0,2", "mcp:local-write": "true", "pp:happy-args": "--query=example question;--resource=res_1;--resource-type=booking"},
+ Long: `Record one or more "this query -> this resource" mappings so the next
+query that matches surfaces the right resources without re-running discovery.
+
+This command is designed to be backgrounded by an LLM right before it
+emits the user-facing response: silent on success, errors only to
+the CLI state directory's teach.log, safe to fire-and-forget.
+
+Disabling: pass --no-learn or set ` + noLearnEnvVar + `=true.`,
+ Example: ` human-goat-pp-cli teach --query "" --resource-type \
+ --resource --resource &`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ if noLearnActive(flags) {
+ return nil
+ }
+ if dryRunOK(flags) {
+ return nil
+ }
+ if strings.TrimSpace(query) == "" {
+ writeTeachErrLog(fmt.Sprintf("teach: missing --query (args=%v resources=%v)", args, resources))
+ return silentCodeErr(2)
+ }
+ if len(resources) == 0 {
+ writeTeachErrLog(fmt.Sprintf("teach: missing --resource for query=%q", query))
+ return silentCodeErr(2)
+ }
+ if strings.TrimSpace(resourceType) == "" {
+ writeTeachErrLog(fmt.Sprintf("teach: missing --resource-type for query=%q", query))
+ return silentCodeErr(2)
+ }
+ if strings.TrimSpace(playbookFile) != "" && strings.TrimSpace(playbookJSONInline) != "" {
+ writeTeachErrLog(fmt.Sprintf("teach: --playbook-file and --playbook-json are mutually exclusive (query=%q)", query))
+ return silentCodeErr(2)
+ }
+ // PII guard (R18): scan the freeform query for obvious
+ // email/phone shapes before it persists into the learn
+ // tables. Warn-only by contract — name the rule on stderr,
+ // never block the teach, never redact silently. The learn
+ // tables are local, but taught rows resurface in future
+ // recall envelopes and may be shared in diagnostics.
+ for _, rule := range learn.ScanPII(query) {
+ fmt.Fprintf(cmd.ErrOrStderr(),
+ "warning: teach query matches the %s PII rule; teach structural queries with identifiers stripped (recorded anyway)\n", rule)
+ }
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: open db: %v", err))
+ return silentCodeErr(1)
+ }
+ defer s.Close()
+
+ normalized := learn.Normalize(query, learnCfg)
+ // Apply entity_lookups promotion symmetrically with recall so
+ // lowercase/numeric-prefix aliases land in query_entities even
+ // when the capitalization-based extractor missed them. Without
+ // this, recall's cross-alias canonical resolver has nothing to
+ // compare against on the stored side, and teach-playbook would
+ // derive a different family for the same query.
+ resolver := learn.NewCanonicalResolver(cmd.Context(), s.DB())
+ normalized = learn.PromoteEntities(normalized, resolver)
+ var taughtRowIDs []int64
+ for _, rid := range resources {
+ rid = strings.TrimSpace(rid)
+ if rid == "" {
+ continue
+ }
+ learningID, _, uerr := s.UpsertLearning(cmd.Context(), store.UpsertLearningInput{
+ Query: query,
+ QueryEntities: normalized.Entities,
+ ResourceID: rid,
+ ResourceType: resourceType,
+ Venue: venueArg,
+ Source: store.LearningSourceTaught,
+ Notes: notes,
+ })
+ if uerr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: upsert %q for query=%q: %v", rid, query, uerr))
+ return silentCodeErr(1)
+ }
+ taughtRowIDs = append(taughtRowIDs, learningID)
+
+ if !noValidate {
+ for _, w := range learn.ValidateResourceShape(cmd.Context(), s.DB(), learnCfg, query, rid, resourceType, nil) {
+ if logErr := learn.AppendTeachLogWarning("teach", query, w); logErr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: warn append: %v", logErr))
+ }
+ }
+ }
+ }
+
+ // Measurement: one teach event per taught row, keyed by the
+ // learning row id so a later recall_hit on the same row
+ // counts as reuse (row-id join; the family hash rides along
+ // as the legacy fallback key). Telemetry-class: failures go
+ // to teach.log and never fail the teach.
+ teachFamHash := learn.FamilyHash(learn.QueryFamily(normalized))
+ teachSurface := store.LearnEventSurface()
+ for _, learningID := range taughtRowIDs {
+ if evErr := s.InsertLearnEvent(store.LearnEventTeach, teachFamHash,
+ learningID, false, teachSurface); evErr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: event insert: %v", evErr))
+ }
+ }
+
+ // Post-teach pattern extraction. The call is cheap and
+ // idempotent (search_patterns unique index silences
+ // duplicates); errors are non-fatal.
+ if _, exErr := patterns.Extract(s.DB(), nil); exErr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: patterns.Extract: %v", exErr))
+ }
+
+ // Optional playbook side: record the structured choreography
+ // + free-text gotchas keyed on the query family. Either
+ // field may be set; both empty means skip. Failures log to
+ // teach.log but don't fail the resource learning above --
+ // the agent's primary write (resource learning) already
+ // succeeded, so degraded playbook recording is acceptable.
+ if strings.TrimSpace(playbookFile) != "" || strings.TrimSpace(playbookJSONInline) != "" || strings.TrimSpace(playbookNotesInline) != "" || strings.TrimSpace(playbookNotesFile) != "" {
+ if pbErr := upsertPlaybookFromTeach(cmd.Context(), s, learnCfg, query, playbookFile, playbookJSONInline, playbookNotesInline, playbookNotesFile, normalized); pbErr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: playbook upsert: %v", pbErr))
+ }
+ }
+
+ // Shared key space with auto-capture: a teach whose query
+ // family matches an open auto-captured playbook candidate
+ // promotes that candidate (materialize if needed + confirm)
+ // instead of leaving a duplicate artifact. Best-effort --
+ // failures log to teach.log and never fail the teach.
+ if promoErr := promoteCandidateOnTeach(s, learn.QueryFamily(normalized)); promoErr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: candidate promotion: %v", promoErr))
+ }
+
+ // Teach-time playbook synthesis, composed with the promotion
+ // hook above: promotion runs first, and a promoted candidate
+ // (or an agent-supplied --playbook-file) leaves a playbook row
+ // that makes synthesis a no-op. Only when the family still
+ // lacks a playbook does this session's recall-to-teach journal
+ // episode become a quarantined playbook_candidate awaiting an
+ // explicit `learnings confirm`. Best-effort — failures log to
+ // teach.log and never fail the teach.
+ if _, _, synthErr := learn.SynthesizePlaybookCandidate(s, learn.QueryFamily(normalized), learn.JournalSessionKey()); synthErr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: playbook synthesis: %v", synthErr))
+ }
+
+ if auditErr := appendLearningsAudit(map[string]any{
+ "action": "teach",
+ "query": query,
+ "normalized": normalized.NonEntityNormalized,
+ "resources": resources,
+ "venue": venueArg,
+ "notes": notes,
+ }); auditErr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach: audit append: %v", auditErr))
+ }
+
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "recorded": true,
+ "query": query,
+ "normalized": normalized.NonEntityNormalized,
+ "resources": resources,
+ }, flags)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&query, "query", "", "User's original natural-language question (required)")
+ cmd.Flags().StringSliceVar(&resources, "resource", nil, "Resource ID — repeat for multiple (required)")
+ cmd.Flags().StringVar(&venueArg, "venue", "", "Optional venue scope tag stored with the learning")
+ cmd.Flags().StringVar(&resourceType, "resource-type", "", "Resource type (required; e.g. matches a row in the local resources table)")
+ cmd.Flags().BoolVar(&quiet, "quiet", true, "Silent on success (default true — designed for background invocation)")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ cmd.Flags().StringVar(¬es, "notes", "", "Optional free-form note recorded in the audit log")
+ cmd.Flags().BoolVar(&noValidate, "no-validate", false, "Suppress teach-time resource-shape validation (warnings to teach.log)")
+ cmd.Flags().StringVar(&playbookFile, "playbook-file", "", "Optional path to a JSON playbook recording the CLI choreography for this query family")
+ cmd.Flags().StringVar(&playbookJSONInline, "playbook-json", "", "Optional inline JSON playbook (mutually exclusive with --playbook-file; for agents without filesystem access)")
+ cmd.Flags().StringVar(&playbookNotesInline, "playbook-notes", "", "Optional inline gotchas/workarounds for this query family (stored alongside the playbook)")
+ cmd.Flags().StringVar(&playbookNotesFile, "playbook-notes-file", "", "Optional path to a markdown file with playbook notes")
+ return cmd
+}
+
+// upsertPlaybookFromTeach loads playbook + notes inputs and writes a
+// learning_playbooks row. Helper for the teach command's optional
+// playbook-side write. Failures bubble up to the caller, which logs
+// them to teach.log without failing the surrounding resource learning.
+//
+// ctx is first per Go convention (and to satisfy staticcheck SA1012).
+// ctx, learnCfg, and query are accepted today for signature parity with
+// the standalone teach-playbook flow; the helper does not yet need them
+// directly because normalize+promote runs in the caller. Keeping the
+// parameters reserved here means future refinements (e.g. a per-call
+// resolver reused for slot validation) don't have to ripple back into
+// the cobra surface.
+func upsertPlaybookFromTeach(ctx context.Context, s *store.Store, learnCfg *entities.Config, query, playbookFile, playbookJSONInline, notesInline, notesFile string, normalized learn.NormalizedQuery) error {
+ playbookJSON, notes, err := resolvePlaybookInputs(playbookFile, notesInline, notesFile)
+ if err != nil {
+ return err
+ }
+ // Inline playbook JSON mirrors the file path's validation: parse
+ // as a Playbook (rejecting garbage early), then re-marshal to the
+ // canonical stored form. The cobra surface enforces the mutual
+ // exclusion with --playbook-file before this helper runs.
+ if strings.TrimSpace(playbookJSONInline) != "" {
+ pb, perr := learn.ParsePlaybook([]byte(playbookJSONInline), "--playbook-json")
+ if perr != nil {
+ return perr
+ }
+ out, merr := learn.MarshalPlaybook(pb)
+ if merr != nil {
+ return fmt.Errorf("teach: re-marshal inline playbook: %w", merr)
+ }
+ playbookJSON = out
+ }
+ if playbookJSON == "" && notes == "" {
+ return nil
+ }
+ family := learn.QueryFamily(normalized)
+ if family == "" {
+ return fmt.Errorf("query normalized to empty family")
+ }
+ _, _, err = s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: family,
+ PlaybookJSON: playbookJSON,
+ NotesText: notes,
+ })
+ return err
+}
+
+// recallEnvelope is the JSON shape returned by `recall --agent`. The
+// LLM consumes this before deciding whether to skip discovery.
+//
+// Playbook + Notes surface the learning_playbooks row matching the
+// query's structural family (when one exists). Older agent prompts
+// that only consume {found, results} continue to work; the new fields
+// are purely additive.
+type recallEnvelope struct {
+ Found bool `json:"found"`
+ Query string `json:"query"`
+ Normalized string `json:"normalized"`
+ QueryEntities []string `json:"query_entities"`
+ MatchScore float64 `json:"match_score,omitempty"`
+ Results []recallEnvelopeResult `json:"results"`
+ Mismatches []recallEnvelopeResult `json:"mismatches,omitempty"`
+ Warnings []string `json:"warnings,omitempty"`
+ Playbook *learn.ResolvedPlaybook `json:"playbook,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ Candidates []learn.Candidate `json:"candidates,omitempty"`
+}
+
+type recallEnvelopeResult struct {
+ ResourceID string `json:"resource_id"`
+ ResourceType string `json:"resource_type,omitempty"`
+ Venue string `json:"venue,omitempty"`
+ Action string `json:"action"`
+ AliasTarget string `json:"alias_target,omitempty"`
+ Confidence int `json:"confidence"`
+ MatchScore float64 `json:"match_score"`
+ EntityMatch string `json:"entity_match,omitempty"`
+ ResourceEntities []string `json:"resource_entities,omitempty"`
+ Source string `json:"source"`
+ LastObservedAt *time.Time `json:"last_observed_at,omitempty"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+// newRecallCmd builds the read side of the loop: an LLM calls recall
+// FIRST when starting work on a new user question, and uses the
+// returned IDs to short-circuit discovery.
+func newRecallCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
+ var minConf int
+ var limit int
+ var dbPath string
+ var debugMismatches bool
+
+ cmd := &cobra.Command{
+ Use: "recall ",
+ Short: "Check prior learnings for a query before running discovery (LLM-fired, pre-discovery)",
+ Long: `Returns prior learnings matching the supplied query by token-set
+overlap (Jaccard >= 0.6) plus entity-aware validation. The LLM should
+call this FIRST when starting work on a new user question; if
+found=true AND results[0].entity_match == "exact" AND confidence >= 2,
+the LLM can skip discovery and go straight to live fetch for the
+returned resource IDs.
+
+Empty match returns {"found": false, "results": []} with exit 0 — this
+is an information query, not a not-found error.
+
+Disabling: ` + noLearnEnvVar + `=true returns the empty shape even
+when learnings exist.`,
+ Example: ` human-goat-pp-cli recall "" --agent
+ human-goat-pp-cli recall "" --agent --min-confidence 2`,
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return cmd.Help()
+ }
+ if dryRunOK(flags) {
+ return nil
+ }
+ query := strings.Join(args, " ")
+ envelope := recallEnvelope{
+ Query: query,
+ QueryEntities: []string{},
+ Results: []recallEnvelopeResult{},
+ }
+ if noLearnActive(flags) {
+ normalized := learn.Normalize(query, learnCfg)
+ envelope.Normalized = normalized.NonEntityNormalized
+ envelope.QueryEntities = append([]string{}, normalized.Entities...)
+ if envelope.QueryEntities == nil {
+ envelope.QueryEntities = []string{}
+ }
+ return emitRecall(cmd, flags, envelope)
+ }
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("recall: %w", err)
+ }
+ defer s.Close()
+
+ result, err := learn.Recall(cmd.Context(), s.DB(), query, learn.Opts{
+ EntityConfig: learnCfg,
+ MinConfidence: minConf,
+ Limit: limit,
+ DebugMismatches: debugMismatches,
+ })
+ if err != nil {
+ return fmt.Errorf("recall: %w", err)
+ }
+ envelope.Found = result.Found
+ envelope.Normalized = result.Normalized
+ envelope.QueryEntities = result.QueryEntities
+ envelope.MatchScore = result.MatchScore
+ envelope.Results = toEnvelopeResults(result.Results)
+ if debugMismatches {
+ envelope.Mismatches = toEnvelopeResults(result.Mismatches)
+ }
+ envelope.Warnings = result.Warnings
+ envelope.Playbook = result.Playbook
+ envelope.Notes = result.Notes
+ envelope.Candidates = result.Candidates
+ // Stage the recall's family + unresolved entities for the
+ // post-run journal entry: flag derivation anchors candidates
+ // on this family, and teach-time synthesis locates the
+ // recall->teach episode by it.
+ learn.SetJournalLearnContext(result.Family, result.UnresolvedEntities)
+ // Measurement: record hit/miss (+ playbook hit) AFTER the
+ // match completed, so the event write never holds the store
+ // lock across the recall scan. Telemetry-class: failures go
+ // to teach.log and never fail the recall.
+ recordRecallEvents(s, result)
+ return emitRecall(cmd, flags, envelope)
+ },
+ }
+ cmd.Flags().IntVar(&minConf, "min-confidence", 1, "Minimum confidence to include in results")
+ cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of learnings to return")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ cmd.Flags().BoolVar(&debugMismatches, "debug-mismatches", false, "Include cross-entity mismatches in the envelope under mismatches[]")
+ return cmd
+}
+
+func toEnvelopeResults(in []learn.Hit) []recallEnvelopeResult {
+ if len(in) == 0 {
+ return []recallEnvelopeResult{}
+ }
+ out := make([]recallEnvelopeResult, 0, len(in))
+ for _, h := range in {
+ out = append(out, recallEnvelopeResult{
+ ResourceID: h.ResourceID,
+ ResourceType: h.ResourceType,
+ Venue: h.Venue,
+ Action: h.Action,
+ AliasTarget: h.AliasTarget,
+ Confidence: h.Confidence,
+ MatchScore: h.MatchScore,
+ EntityMatch: h.EntityMatch,
+ ResourceEntities: h.ResourceEntities,
+ Source: h.Source,
+ LastObservedAt: h.LastObservedAt,
+ Warnings: h.Warnings,
+ })
+ }
+ return out
+}
+
+func emitRecall(cmd *cobra.Command, flags *rootFlags, env recallEnvelope) error {
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), env, flags)
+ }
+ if !env.Found {
+ fmt.Fprintf(cmd.OutOrStdout(), "no prior learnings for %q\n", env.Query)
+ return nil
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "%d learning(s) for %q (top match %.2f):\n", len(env.Results), env.Query, env.MatchScore)
+ for _, r := range env.Results {
+ fmt.Fprintf(cmd.OutOrStdout(), " %s\t%s\tconf=%d\tmatch=%.2f\n", r.Action, r.ResourceID, r.Confidence, r.MatchScore)
+ }
+ return nil
+}
+
+// recordRecallEvents writes the recall measurement rows: recall_hit
+// with the top hit's backing row id (so teach-to-reuse joins by row
+// id) or recall_miss, plus recall_playbook_hit when the family
+// resolved a playbook. Called strictly after learn.Recall returns —
+// the event insert must never overlap the match. Best-effort by
+// contract: every failure lands in teach.log, none reaches the caller.
+func recordRecallEvents(s *store.Store, result learn.Result) {
+ if s == nil {
+ return
+ }
+ surface := store.LearnEventSurface()
+ famHash := learn.FamilyHash(result.Family)
+ if result.Found && len(result.Results) > 0 {
+ top := result.Results[0]
+ if err := s.InsertLearnEvent(store.LearnEventRecallHit, famHash,
+ top.LearningID, top.EntityMatch == learn.EntityMatchExact, surface); err != nil {
+ writeTeachErrLog(fmt.Sprintf("recall: event insert (hit): %v", err))
+ }
+ } else {
+ if err := s.InsertLearnEvent(store.LearnEventRecallMiss, famHash,
+ 0, false, surface); err != nil {
+ writeTeachErrLog(fmt.Sprintf("recall: event insert (miss): %v", err))
+ }
+ }
+ if result.Playbook != nil {
+ if err := s.InsertLearnEvent(store.LearnEventRecallPlaybookHit, famHash,
+ 0, false, surface); err != nil {
+ writeTeachErrLog(fmt.Sprintf("recall: event insert (playbook hit): %v", err))
+ }
+ }
+}
+
+// newLearningsCmd is the human-inspection parent. `learnings list` lists
+// rows; `learnings forget` removes them.
+func newLearningsCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "learnings",
+ Short: "Inspect or forget the local search_learnings table",
+ Long: `Surface for browsing, filtering, and deleting rows in the
+search_learnings table that the LLM populates via the 'teach' command.`,
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: parentNoSubcommandRunE(flags),
+ }
+ cmd.AddCommand(newLearningsListCmd(flags))
+ cmd.AddCommand(newLearningsForgetCmd(flags, learnCfg))
+ // Candidate lifecycle control surface (candidates | confirm |
+ // reject | purge) -- defined in learnings_candidates.go.
+ registerLearningsCandidateCommands(cmd, flags)
+ // Measurement surface (`learnings stats`) -- defined in
+ // learnings_stats.go.
+ cmd.AddCommand(newLearningsStatsCmd(flags))
+ return cmd
+}
+
+// learningRow is the JSON output shape for `learnings list`. Mirrors
+// store.LearningRow but locks the JSON contract at the CLI layer so
+// agents that key on it across PP versions stay stable across
+// generator upgrades.
+type learningRow struct {
+ ID int64 `json:"id"`
+ QueryPattern string `json:"query_pattern"`
+ ResourceID string `json:"resource_id"`
+ ResourceType string `json:"resource_type,omitempty"`
+ Venue string `json:"venue,omitempty"`
+ Action string `json:"action"`
+ AliasTarget string `json:"alias_target,omitempty"`
+ Confidence int `json:"confidence"`
+ Source string `json:"source"`
+ CreatedAt time.Time `json:"created_at"`
+ LastObservedAt *time.Time `json:"last_observed_at,omitempty"`
+ Notes string `json:"notes,omitempty"`
+}
+
+// listLearningsRows queries the store via the canonical ListLearnings
+// API and translates each row into the CLI envelope shape.
+func listLearningsRows(ctx context.Context, s *store.Store, f store.ListLearningsFilter) ([]learningRow, error) {
+ stored, err := s.ListLearnings(ctx, f)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]learningRow, 0, len(stored))
+ for _, r := range stored {
+ out = append(out, learningRow{
+ ID: r.ID,
+ QueryPattern: r.QueryPattern,
+ ResourceID: r.ResourceID,
+ ResourceType: r.ResourceType,
+ Venue: r.Venue,
+ Action: r.Action,
+ AliasTarget: r.AliasTarget,
+ Confidence: r.Confidence,
+ Source: r.Source,
+ CreatedAt: r.CreatedAt,
+ LastObservedAt: r.LastObservedAt,
+ Notes: r.Notes,
+ })
+ }
+ return out, nil
+}
+
+func newLearningsListCmd(flags *rootFlags) *cobra.Command {
+ var queryFilter string
+ var sourceFilter string
+ var resourceFilter string
+ var actionFilter string
+ var minConf int
+ var limit int
+ var dbPath string
+ var warningsOnly bool
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List recorded learnings",
+ Example: ` human-goat-pp-cli learnings list --agent
+ human-goat-pp-cli learnings list --query ""
+ human-goat-pp-cli learnings list --warnings --agent`,
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ if warningsOnly {
+ var filterIDs []string
+ if strings.TrimSpace(resourceFilter) != "" {
+ filterIDs = []string{resourceFilter}
+ }
+ entries, err := learn.ReadTeachLogWarnings(filterIDs...)
+ if err != nil {
+ return fmt.Errorf("learnings list --warnings: %w", err)
+ }
+ if entries == nil {
+ entries = []learn.TeachLogEntry{}
+ }
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "warnings": entries,
+ }, flags)
+ }
+ if len(entries) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "(no teach.log warnings recorded)")
+ return nil
+ }
+ for _, e := range entries {
+ fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\tsuggested=%s\n",
+ e.TS, e.Warning, e.Resource, e.Suggested)
+ }
+ return nil
+ }
+
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("learnings list: %w", err)
+ }
+ defer s.Close()
+
+ rows, err := listLearningsRows(cmd.Context(), s, store.ListLearningsFilter{
+ Query: queryFilter,
+ Source: sourceFilter,
+ ResourceID: resourceFilter,
+ Action: actionFilter,
+ MinConfidence: minConf,
+ Limit: limit,
+ })
+ if err != nil {
+ return fmt.Errorf("learnings list: %w", err)
+ }
+
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
+ }
+ if len(rows) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "(no learnings recorded)")
+ return nil
+ }
+ for _, r := range rows {
+ fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\tconf=%d\tsource=%s\n",
+ r.QueryPattern, r.Action, r.ResourceID, r.Confidence, r.Source)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&queryFilter, "query", "", "Filter by normalized query substring")
+ cmd.Flags().StringVar(&sourceFilter, "source", "", "Filter by source (taught | manual-forget)")
+ cmd.Flags().StringVar(&resourceFilter, "resource", "", "Filter by resource_id")
+ cmd.Flags().StringVar(&actionFilter, "action", "", "Filter by action (boost | hide | alias_of)")
+ cmd.Flags().IntVar(&minConf, "min-confidence", 0, "Filter by minimum confidence")
+ cmd.Flags().IntVar(&limit, "limit", 200, "Maximum rows to return")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ cmd.Flags().BoolVar(&warningsOnly, "warnings", false, "Return teach-time warnings from teach.log instead of stored learning rows")
+ return cmd
+}
+
+// forgetLearningsRows deletes search_learnings rows matching the
+// canonical store.ForgetLearningsFilter and returns the count
+// removed. Requires at least one of ResourceID, Action, or All on the
+// filter (enforced by the store side).
+func forgetLearningsRows(ctx context.Context, s *store.Store, query string, f store.ForgetLearningsFilter) (int64, error) {
+ if s == nil {
+ return 0, errors.New("forgetLearningsRows: store is nil")
+ }
+ if strings.TrimSpace(query) == "" {
+ return 0, errors.New("forgetLearningsRows: query is required")
+ }
+ f.Query = query
+ return s.ForgetLearnings(ctx, f)
+}
+
+func newLearningsForgetCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
+ var resourceArg string
+ var actionArg string
+ var all bool
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "forget ",
+ Short: "Delete learnings matching a query (use --all to wipe every rule for that query)",
+ Long: `Removes rows from the search_learnings table so a bad teach can be
+undone without dropping the whole DB.
+
+Requires at least one of --resource, --action, or --all.`,
+ Example: ` human-goat-pp-cli learnings forget "" --resource
+ human-goat-pp-cli learnings forget "" --all`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return cmd.Help()
+ }
+ if dryRunOK(flags) {
+ return nil
+ }
+ query := strings.Join(args, " ")
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("learnings forget: %w", err)
+ }
+ defer s.Close()
+
+ n, err := forgetLearningsRows(cmd.Context(), s, query, store.ForgetLearningsFilter{
+ ResourceID: resourceArg,
+ Action: actionArg,
+ All: all,
+ })
+ if err != nil {
+ return usageErr(fmt.Errorf("learnings forget: %w", err))
+ }
+ // Forget completes the loop's undo (R18): the forgotten
+ // family's measurement trail cascades away with the
+ // learning, then a single forget event records that the
+ // family was wiped (inserted after the cascade so it
+ // survives). Family derivation mirrors teach: normalize +
+ // entity promotion, so the same query text keys the same
+ // family on both paths. Telemetry-class: failures log to
+ // teach.log and never fail the forget.
+ if n > 0 {
+ normalized := learn.PromoteEntities(
+ learn.Normalize(query, learnCfg),
+ learn.NewCanonicalResolver(cmd.Context(), s.DB()))
+ famHash := learn.FamilyHash(learn.QueryFamily(normalized))
+ if _, cascErr := s.DeleteLearnEventsByFamilyHash(famHash); cascErr != nil {
+ writeTeachErrLog(fmt.Sprintf("learnings forget: event cascade: %v", cascErr))
+ }
+ if evErr := s.InsertLearnEvent(store.LearnEventForget, famHash,
+ 0, false, store.LearnEventSurface()); evErr != nil {
+ writeTeachErrLog(fmt.Sprintf("learnings forget: event insert: %v", evErr))
+ }
+ }
+ _ = appendLearningsAudit(map[string]any{
+ "action": "forget",
+ "query": query,
+ "filter": map[string]any{"resource": resourceArg, "action": actionArg, "all": all},
+ "rows_deleted": n,
+ })
+
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "deleted": n,
+ "query": query,
+ }, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "forgot %d learning(s) for %q\n", n, query)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&resourceArg, "resource", "", "Delete only the rule for this resource_id")
+ cmd.Flags().StringVar(&actionArg, "action", "", "Delete only rules with this action (boost | hide | alias_of)")
+ cmd.Flags().BoolVar(&all, "all", false, "Delete every rule for the supplied query")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
+
+// newTeachPatternCmd installs a manual generalization pattern. The
+// recall path uses patterns to substitute entities into resource
+// templates.
+func newTeachPatternCmd(flags *rootFlags) *cobra.Command {
+ var queryTemplate string
+ var resourceTemplate string
+ var resourceType string
+ var venue string
+ var strategy string
+ var entityKind string
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "teach-pattern",
+ Short: "Install a manual generalization pattern (query_template, resource_template, entity_kind)",
+ Annotations: map[string]string{"pp:happy-args": "--query-template=items in {entity};--resource-template=GROUP-{entity};--resource-type=items;--entity-kind=category"},
+ Long: `Adds one row to search_patterns. The recall path uses patterns to
+substitute live query entities into a resource template, so a pattern
+with query_template="items in {entity}" and
+resource_template="GROUP-{entity:category}" generalizes one teach into
+a whole family.`,
+ Example: ` human-goat-pp-cli teach-pattern \
+ --query-template "items in {entity}" \
+ --resource-template "GROUP-{entity:category}" \
+ --resource-type "items" \
+ --entity-kind "category" \
+ --strategy substitute`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ if noLearnActive(flags) {
+ return nil
+ }
+ if strings.TrimSpace(queryTemplate) == "" {
+ return usageErr(fmt.Errorf("--query-template is required"))
+ }
+ if strings.TrimSpace(resourceTemplate) == "" {
+ return usageErr(fmt.Errorf("--resource-template is required"))
+ }
+ if strings.TrimSpace(resourceType) == "" {
+ return usageErr(fmt.Errorf("--resource-type is required"))
+ }
+ if strings.TrimSpace(entityKind) == "" {
+ return usageErr(fmt.Errorf("--entity-kind is required"))
+ }
+ if strategy == "" {
+ strategy = patterns.StrategySubstitute
+ }
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("teach-pattern: %w", err)
+ }
+ defer s.Close()
+
+ id, inserted, err := patterns.Upsert(s.DB(), patterns.Pattern{
+ QueryTemplate: queryTemplate,
+ ResourceTemplate: resourceTemplate,
+ ResourceType: resourceType,
+ Venue: venue,
+ Strategy: strategy,
+ EntityKind: entityKind,
+ Source: patterns.SourceTaught,
+ })
+ if err != nil {
+ return fmt.Errorf("teach-pattern: %w", err)
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "recorded": true,
+ "newly_inserted": inserted,
+ "pattern_id": id,
+ "query_template": queryTemplate,
+ "resource_template": resourceTemplate,
+ "resource_type": resourceType,
+ "venue": venue,
+ "strategy": strategy,
+ "entity_kind": entityKind,
+ }, flags)
+ },
+ }
+ cmd.Flags().StringVar(&queryTemplate, "query-template", "", "Query template with {entity} placeholder (required)")
+ cmd.Flags().StringVar(&resourceTemplate, "resource-template", "", "Resource template with typed entity slot, e.g. PREFIX-{entity:country} (required)")
+ cmd.Flags().StringVar(&resourceType, "resource-type", "", "Resource type for substituted IDs (required)")
+ cmd.Flags().StringVar(&venue, "venue", "", "Optional venue scope tag")
+ cmd.Flags().StringVar(&strategy, "strategy", "", "Substitution strategy: substitute | substitute-then-search-prefix (default substitute)")
+ cmd.Flags().StringVar(&entityKind, "entity-kind", "", "Entity kind name registered in entity_lookups (required)")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
+
+// newTeachLookupCmd installs a manual entity-lookup row.
+func newTeachLookupCmd(flags *rootFlags) *cobra.Command {
+ var kind string
+ var canonical string
+ var value string
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "teach-lookup",
+ Short: "Install a manual entity-lookup row (kind, canonical, value)",
+ Long: `Adds one row to entity_lookups. Used by the recall path's pattern
+engine to substitute values for canonical names at substitution time.
+Computed kinds (lowercase, uppercase, kebab-case, capitalize-first, slug)
+cannot be taught — they are derived from the canonical input.`,
+ Example: ` human-goat-pp-cli teach-lookup --kind country --canonical "United States" --value USA`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ if noLearnActive(flags) {
+ return nil
+ }
+ if strings.TrimSpace(kind) == "" {
+ return usageErr(fmt.Errorf("--kind is required"))
+ }
+ if strings.TrimSpace(canonical) == "" {
+ return usageErr(fmt.Errorf("--canonical is required"))
+ }
+ if strings.TrimSpace(value) == "" {
+ return usageErr(fmt.Errorf("--value is required"))
+ }
+ if lookups.IsComputedKind(kind) {
+ return usageErr(fmt.Errorf("--kind %q is a computed kind and cannot be taught", kind))
+ }
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("teach-lookup: %w", err)
+ }
+ defer s.Close()
+
+ if err := lookups.Upsert(s.DB(), kind, canonical, value, "taught"); err != nil {
+ return fmt.Errorf("teach-lookup: %w", err)
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "recorded": true,
+ "kind": kind,
+ "canonical": canonical,
+ "value": value,
+ }, flags)
+ },
+ }
+ cmd.Flags().StringVar(&kind, "kind", "", "Entity kind, e.g. country, team (required, must not be a computed kind)")
+ cmd.Flags().StringVar(&canonical, "canonical", "", "Canonical entity name (required)")
+ cmd.Flags().StringVar(&value, "value", "", "Value/alias that should resolve to the canonical (required)")
+ cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
+ return cmd
+}
diff --git a/library/productivity/human-goat/internal/cli/teach_playbook.go b/library/productivity/human-goat/internal/cli/teach_playbook.go
new file mode 100644
index 0000000000..e8343567e6
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/teach_playbook.go
@@ -0,0 +1,385 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// teach_playbook.go implements the playbook write surface. Standalone
+// `teach-playbook` records a query-family-keyed playbook (structured
+// CLI choreography) plus optional free-text notes (gotchas the CLI
+// surface doesn't expose). `playbook list` is the inspection
+// counterpart to `learnings list`. `playbook amend` is the LLM-fired
+// self-correction surface: a one-line CLI call appends a timestamped
+// note to an existing family's notes_text via the atomic
+// AppendPlaybookNotes path so concurrent amends can't race-overwrite.
+
+package cli
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// newTeachPlaybookCmd builds the standalone command for recording a
+// playbook + notes pair for a query family. Use this when only the
+// recipe is being recorded (no resource learning to attach). For the
+// common end-of-session flow, prefer `teach --playbook-file --notes`
+// so both surfaces land in one shot.
+func newTeachPlaybookCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
+ var query string
+ var playbookFile string
+ var playbookJSONInline string
+ var notesText string
+ var notesFile string
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "teach-playbook",
+ Short: "Record a CLI playbook + free-text notes for a query family",
+ // Bare invocation intentionally exits 2 (usage error: --query and a
+ // playbook/notes input are required); declare it so
+ // `cli-printing-press verify` scores the Execute cell honestly.
+ // mcp:local-write: the only writes land in the CLI's own local
+ // learn store, so MCP hosts see destructive=false + openWorld=false.
+ Annotations: map[string]string{
+ "pp:typed-exit-codes": "0,2",
+ "mcp:local-write": "true",
+ "pp:happy-args": "--query=example query;--notes=example gotcha note",
+ },
+ Long: `Stores a structured CLI command sequence (with entity slots) and/or
+free-form gotchas/workarounds, keyed on the structural query family.
+The recall path surfaces this whenever a future query of the same
+family fires, so the agent can replay the choreography and read the
+notes verbatim.
+
+At least one of --playbook-json/--playbook-file and --notes/--notes-file
+must be set. --playbook-json takes the playbook body inline so MCP-only
+agents can record playbooks without a file on disk.`,
+ Example: ` human-goat-pp-cli teach-playbook \
+ --query "" \
+ --playbook-file ~/playbooks/recipe.json \
+ --notes-file ~/playbooks/recipe-notes.md`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ if noLearnActive(flags) {
+ return nil
+ }
+ if dryRunOK(flags) {
+ return nil
+ }
+ if strings.TrimSpace(query) == "" {
+ return usageErr(fmt.Errorf("--query is required"))
+ }
+ if strings.TrimSpace(playbookFile) != "" && strings.TrimSpace(playbookJSONInline) != "" {
+ return usageErr(fmt.Errorf("--playbook-json and --playbook-file are mutually exclusive"))
+ }
+ if strings.TrimSpace(playbookFile) == "" && strings.TrimSpace(playbookJSONInline) == "" && strings.TrimSpace(notesText) == "" && strings.TrimSpace(notesFile) == "" {
+ return usageErr(fmt.Errorf("at least one of --playbook-json, --playbook-file, --notes, --notes-file is required"))
+ }
+
+ playbookJSON, notes, err := resolvePlaybookInputs(playbookFile, notesText, notesFile)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(playbookJSONInline) != "" {
+ playbookJSON, err = resolveInlinePlaybook(playbookJSONInline)
+ if err != nil {
+ return err
+ }
+ }
+
+ normalized := learn.Normalize(query, learnCfg)
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("teach-playbook: open db: %w", err)
+ }
+ defer s.Close()
+ resolver := learn.NewCanonicalResolver(cmd.Context(), s.DB())
+ normalized = learn.PromoteEntities(normalized, resolver)
+ family := learn.QueryFamily(normalized)
+ if family == "" {
+ return fmt.Errorf("teach-playbook: query normalized to empty family")
+ }
+
+ id, inserted, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: family,
+ PlaybookJSON: playbookJSON,
+ NotesText: notes,
+ })
+ if err != nil {
+ return fmt.Errorf("teach-playbook: %w", err)
+ }
+
+ // Usage-event insert is best-effort telemetry: failures go to
+ // teach.log and never fail the teach-playbook.
+ if evErr := s.InsertLearnEvent(store.LearnEventTeachPlaybook,
+ learn.FamilyHash(family), 0, false, store.LearnEventSurface()); evErr != nil {
+ writeTeachErrLog(fmt.Sprintf("teach-playbook: event insert: %v", evErr))
+ }
+
+ _ = appendLearningsAudit(map[string]any{
+ "action": "teach-playbook",
+ "query": query,
+ "query_family": family,
+ "playbook_id": id,
+ "newly_inserted": inserted,
+ })
+
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "recorded": true,
+ "playbook_id": id,
+ "inserted": inserted,
+ "query_family": family,
+ "has_playbook": playbookJSON != "",
+ "has_notes": notes != "",
+ }, flags)
+ },
+ }
+ cmd.Flags().StringVar(&query, "query", "", "Example query that anchors the family (required)")
+ cmd.Flags().StringVar(&playbookFile, "playbook-file", "", "Path to a JSON file with the playbook (steps, entity_slots, expected_tool_calls)")
+ cmd.Flags().StringVar(&playbookJSONInline, "playbook-json", "", "Inline playbook JSON (steps, entity_slots, expected_tool_calls) -- mutually exclusive with --playbook-file")
+ cmd.Flags().StringVar(¬esText, "notes", "", "Free-text notes (gotchas, workarounds) -- mutually exclusive with --notes-file")
+ cmd.Flags().StringVar(¬esFile, "notes-file", "", "Path to a markdown file with the notes")
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: standard cache location)")
+ return cmd
+}
+
+// newPlaybookCmd is the inspection + amendment parent. `playbook list`
+// lists stored playbooks; `playbook amend` appends notes to an
+// existing family.
+func newPlaybookCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "playbook",
+ Short: "Inspect or amend stored CLI playbooks",
+ RunE: parentNoSubcommandRunE(flags),
+ }
+ cmd.AddCommand(newPlaybookListCmd(flags))
+ cmd.AddCommand(newPlaybookAmendCmd(flags, learnCfg))
+ return cmd
+}
+
+// newPlaybookAmendCmd is the self-correction surface: a one-line
+// CLI call that appends a timestamped note to an existing playbook's
+// notes_text (or creates a notes-only playbook if the family has
+// none). Designed for agents to fire when their debug-protocol
+// response identifies a concrete correction worth persisting.
+//
+// Same fire-and-forget posture as `teach` -- silent on success,
+// errors to teach.log, safe to background with &.
+func newPlaybookAmendCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
+ var query string
+ var addNote string
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "amend",
+ Short: "Append a note to an existing playbook (LLM-fired self-correction, silent)",
+ // Bare invocation intentionally exits 2 (silent usage error to
+ // teach.log); declare it so `cli-printing-press verify` scores the
+ // Execute cell honestly instead of masking a FAIL.
+ // mcp:local-write: appends only touch the CLI's own local learn
+ // store, so MCP hosts see destructive=false + openWorld=false.
+ Annotations: map[string]string{
+ "pp:typed-exit-codes": "0,2",
+ "mcp:local-write": "true",
+ "pp:happy-args": "--query=example query;--add-note=example note",
+ },
+ Long: `Appends a timestamped note to the matching family's playbook notes_text.
+If no playbook exists for the family yet, creates a notes-only one.
+
+Designed for agents to fire when their debug-protocol response
+identifies a concrete correction worth persisting (a workaround, an
+undocumented endpoint shape, a stale field name, observed schema
+drift). Same fire-and-forget posture as teach: silent on success,
+errors to teach.log, safe to background with &.
+
+Disabling: pass --no-learn or set ` + noLearnEnvVar + `=true.`,
+ Example: ` human-goat-pp-cli playbook amend \
+ --query "" \
+ --add-note "summary endpoint envelope: data lives at .results.header, not .header"`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cmd.SilenceUsage = true
+ cmd.SilenceErrors = true
+ if noLearnActive(flags) {
+ return nil
+ }
+ if dryRunOK(flags) {
+ return nil
+ }
+ if strings.TrimSpace(query) == "" {
+ writeTeachErrLog(fmt.Sprintf("playbook amend: missing --query (args=%v)", args))
+ return silentCodeErr(2)
+ }
+ if strings.TrimSpace(addNote) == "" {
+ writeTeachErrLog(fmt.Sprintf("playbook amend: missing --add-note for query=%q", query))
+ return silentCodeErr(2)
+ }
+
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ writeTeachErrLog(fmt.Sprintf("playbook amend: open db: %v", err))
+ return silentCodeErr(1)
+ }
+ defer s.Close()
+
+ normalized := learn.Normalize(query, learnCfg)
+ resolver := learn.NewCanonicalResolver(cmd.Context(), s.DB())
+ normalized = learn.PromoteEntities(normalized, resolver)
+ family := learn.QueryFamily(normalized)
+ if family == "" {
+ writeTeachErrLog(fmt.Sprintf("playbook amend: query normalized to empty family: %q", query))
+ return silentCodeErr(2)
+ }
+
+ // AppendPlaybookNotes runs the read+update inside a single
+ // transaction under writeMu, so two concurrent
+ // `playbook amend` calls for the same family cannot
+ // race-overwrite each other (e.g. when SKILL.md tells the
+ // agent to background amend with `&` across overlapping
+ // sessions).
+ marker := fmt.Sprintf("\n\n[amend %s]: %s", time.Now().UTC().Format("2006-01-02T15:04Z"), addNote)
+ if _, _, err := s.AppendPlaybookNotes(family, marker); err != nil {
+ writeTeachErrLog(fmt.Sprintf("playbook amend: append family=%q: %v", family, err))
+ return silentCodeErr(1)
+ }
+
+ // Amend events feed the playbook-resolution metric (hits later
+ // amended count against resolution success). Best-effort only.
+ if evErr := s.InsertLearnEvent(store.LearnEventAmend,
+ learn.FamilyHash(family), 0, false, store.LearnEventSurface()); evErr != nil {
+ writeTeachErrLog(fmt.Sprintf("playbook amend: event insert: %v", evErr))
+ }
+
+ if auditErr := appendLearningsAudit(map[string]any{
+ "action": "playbook-amend",
+ "query": query,
+ "query_family": family,
+ "add_note": addNote,
+ }); auditErr != nil {
+ writeTeachErrLog(fmt.Sprintf("playbook amend: audit append: %v", auditErr))
+ }
+
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "amended": true,
+ "query": query,
+ "query_family": family,
+ }, flags)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&query, "query", "", "The exact recall query whose family should be amended (required)")
+ cmd.Flags().StringVar(&addNote, "add-note", "", "The note text to append to the family's playbook (required)")
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: standard cache location)")
+ return cmd
+}
+
+func newPlaybookListCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List stored playbooks (query_family, content presence, last observed)",
+ Example: ` human-goat-pp-cli playbook list --agent`,
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dryRunOK(flags) {
+ return nil
+ }
+ dbPath = learnDBPath(dbPath)
+ s, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("playbook list: %w", err)
+ }
+ defer s.Close()
+ rows, err := s.ListPlaybooks()
+ if err != nil {
+ return fmt.Errorf("playbook list: %w", err)
+ }
+ if rows == nil {
+ rows = []store.PlaybookRow{}
+ }
+
+ _ = appendLearningsAudit(map[string]any{
+ "action": "playbook-list",
+ "rows": len(rows),
+ })
+
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
+ }
+ if len(rows) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "(no playbooks recorded)")
+ return nil
+ }
+ for _, r := range rows {
+ lastObs := ""
+ if r.LastObservedAt != nil {
+ lastObs = r.LastObservedAt.UTC().Format(time.RFC3339)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "%s\tplaybook=%v\tnotes=%v\tconf=%d\t%s\n",
+ r.QueryFamily, r.PlaybookJSON != "", r.NotesText != "", r.Confidence, lastObs)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: standard cache location)")
+ return cmd
+}
+
+// resolveInlinePlaybook validates and canonicalizes an inline
+// --playbook-json value, the file-free write path for MCP-only agents.
+// Kept separate from resolvePlaybookInputs so the file-based signature
+// shared with `teach --playbook-file` stays untouched.
+func resolveInlinePlaybook(playbookInline string) (string, error) {
+ // Validate that it parses as a Playbook; reject garbage early.
+ pb, err := learn.ParsePlaybook([]byte(playbookInline), "--playbook-json")
+ if err != nil {
+ return "", fmt.Errorf("teach-playbook: %w", err)
+ }
+ out, err := learn.MarshalPlaybook(pb)
+ if err != nil {
+ return "", fmt.Errorf("teach-playbook: re-marshal: %w", err)
+ }
+ return out, nil
+}
+
+// resolvePlaybookInputs loads the playbook JSON (if a file path was
+// given) and the notes text (preferring inline --notes over
+// --notes-file). Returns (playbookJSON, notesText, error). Either
+// output may be empty; the caller validates at-least-one-non-empty.
+func resolvePlaybookInputs(playbookFile, notesInline, notesFile string) (string, string, error) {
+ var playbookJSON string
+ if strings.TrimSpace(playbookFile) != "" {
+ // Validate that it parses as a Playbook; reject garbage early.
+ pb, err := learn.ParsePlaybookFile(playbookFile)
+ if err != nil {
+ return "", "", fmt.Errorf("teach-playbook: %w", err)
+ }
+ out, err := learn.MarshalPlaybook(pb)
+ if err != nil {
+ return "", "", fmt.Errorf("teach-playbook: re-marshal: %w", err)
+ }
+ playbookJSON = out
+ }
+ var notes string
+ if strings.TrimSpace(notesInline) != "" {
+ notes = notesInline
+ } else if strings.TrimSpace(notesFile) != "" {
+ data, err := os.ReadFile(notesFile)
+ if err != nil {
+ return "", "", fmt.Errorf("teach-playbook: read notes file %s: %w", notesFile, err)
+ }
+ notes = string(data)
+ }
+ return playbookJSON, notes, nil
+}
diff --git a/library/productivity/human-goat/internal/cli/teach_playbook_test.go b/library/productivity/human-goat/internal/cli/teach_playbook_test.go
new file mode 100644
index 0000000000..597f69ba36
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/teach_playbook_test.go
@@ -0,0 +1,486 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// writePlaybookFile is a test helper that drops a fixture file at the
+// supplied path under the test temp dir. Returns the absolute path so
+// callers can pass it straight to --playbook-file / --notes-file.
+func writePlaybookFile(t *testing.T, dir, name, content string) string {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+ return path
+}
+
+func TestTeachPlaybook_HappyPath(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ playbookPath := writePlaybookFile(t, home, "p.json",
+ `{"steps":[{"cmd":"items list {category.id}"}],"entity_slots":["$CATEGORY"]}`)
+ notesPath := writePlaybookFile(t, home, "n.md",
+ "the items-by-source endpoint needs source_type=2; duplicate labels")
+
+ stdout, stderr, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "list all widgets in inventory and their top stats",
+ "--playbook-file", playbookPath,
+ "--notes-file", notesPath,
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("teach-playbook: %v (stderr=%q)", err, stderr)
+ }
+ var resp map[string]any
+ unmarshalAgentResults(t, stdout, &resp)
+ if v, _ := resp["recorded"].(bool); !v {
+ t.Errorf("recorded should be true, got %v", resp)
+ }
+ if v, _ := resp["has_playbook"].(bool); !v {
+ t.Errorf("has_playbook should be true, got %v", resp)
+ }
+ if v, _ := resp["has_notes"].(bool); !v {
+ t.Errorf("has_notes should be true, got %v", resp)
+ }
+
+ // SQL-inspect to confirm the playbook row landed with both surfaces.
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer s.Close()
+ rows, err := s.ListPlaybooks()
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("want 1 playbook row, got %d", len(rows))
+ }
+ if !strings.Contains(rows[0].NotesText, "source_type=2") {
+ t.Errorf("notes_text not stored: %q", rows[0].NotesText)
+ }
+ if !strings.Contains(rows[0].PlaybookJSON, "items list") {
+ t.Errorf("playbook_json not stored: %q", rows[0].PlaybookJSON)
+ }
+}
+
+func TestTeachPlaybook_InlineJSON(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ // No file on disk anywhere: the playbook body rides the flag itself,
+ // the MCP-only agent path.
+ stdout, stderr, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "list all widgets in inventory and their top stats",
+ "--playbook-json", `{"steps":[{"cmd":"items list {category.id}"}],"entity_slots":["$CATEGORY"]}`,
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("teach-playbook --playbook-json: %v (stderr=%q)", err, stderr)
+ }
+ var resp map[string]any
+ unmarshalAgentResults(t, stdout, &resp)
+ if v, _ := resp["recorded"].(bool); !v {
+ t.Errorf("recorded should be true, got %v", resp)
+ }
+ if v, _ := resp["has_playbook"].(bool); !v {
+ t.Errorf("has_playbook should be true, got %v", resp)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer s.Close()
+ rows, err := s.ListPlaybooks()
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("want 1 playbook row, got %d", len(rows))
+ }
+ if !strings.Contains(rows[0].PlaybookJSON, "items list") {
+ t.Errorf("playbook_json not stored from inline flag: %q", rows[0].PlaybookJSON)
+ }
+}
+
+func TestTeachPlaybook_InlineJSONMalformed(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "any",
+ "--playbook-json", "{not valid json",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("expected error for malformed inline playbook JSON")
+ }
+}
+
+func TestTeachPlaybook_InlineAndFileMutuallyExclusive(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ pbPath := writePlaybookFile(t, home, "p.json", `{"steps":[{"cmd":"x"}]}`)
+
+ _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "any",
+ "--playbook-file", pbPath,
+ "--playbook-json", `{"steps":[{"cmd":"y"}]}`,
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("expected error when both --playbook-file and --playbook-json are provided")
+ }
+ if !strings.Contains(err.Error(), "mutually exclusive") {
+ t.Errorf("error should name the mutual exclusion; got %v", err)
+ }
+
+ s, statErr := store.OpenWithContext(context.Background(), dbPath)
+ if statErr == nil {
+ defer s.Close()
+ if rows, _ := s.ListPlaybooks(); len(rows) != 0 {
+ t.Errorf("mutually-exclusive usage error must not write; got %d rows", len(rows))
+ }
+ }
+}
+
+func TestTeachPlaybook_NotesOnly(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "how to interpret division summaries",
+ "--notes", "always read both group_id and parent_id",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("teach-playbook: %v", err)
+ }
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := s.ListPlaybooks()
+ if len(rows) != 1 {
+ t.Fatalf("want 1 row, got %d", len(rows))
+ }
+ if rows[0].PlaybookJSON != "" {
+ t.Errorf("playbook_json should be empty for notes-only, got %q", rows[0].PlaybookJSON)
+ }
+ if !strings.Contains(rows[0].NotesText, "group_id") {
+ t.Errorf("notes_text content missing")
+ }
+}
+
+func TestTeachPlaybook_MissingFile(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "any query",
+ "--playbook-file", filepath.Join(home, "nonexistent.json"),
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("expected error for missing playbook file")
+ }
+}
+
+func TestTeachPlaybook_MalformedJSON(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ badPath := writePlaybookFile(t, home, "bad.json", "{not valid json")
+
+ _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "any",
+ "--playbook-file", badPath,
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("expected error for malformed JSON")
+ }
+}
+
+func TestTeachPlaybook_RequiresContent(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "x",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("expected error when neither playbook nor notes provided")
+ }
+}
+
+func TestTeachPlaybook_RequiresQuery(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ notesPath := writePlaybookFile(t, home, "n.md", "some notes")
+
+ _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--notes-file", notesPath,
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("expected error when --query is missing")
+ }
+}
+
+func TestTeachPlaybook_RespectsNoLearn(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ t.Setenv(noLearnEnvVar, "true")
+
+ stdout, stderr, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "should noop",
+ "--notes", "should noop",
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach-playbook with NO_LEARN should be silent noop: %v", err)
+ }
+ if stdout != "" || stderr != "" {
+ t.Errorf("NO_LEARN should be silent; got stdout=%q stderr=%q", stdout, stderr)
+ }
+ if _, statErr := os.Stat(dbPath); statErr == nil {
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := s.ListPlaybooks()
+ if len(rows) != 0 {
+ t.Errorf("NO_LEARN should suppress writes; got %d rows", len(rows))
+ }
+ }
+}
+
+func TestPlaybookAmend_HappyPath_ExistingPlaybook(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ pbPath := writePlaybookFile(t, home, "p.json", `{"steps":[{"cmd":"x"}],"query_family_examples":["foo bar baz"]}`)
+
+ // Seed an existing playbook for "foo bar baz" family via teach-playbook
+ if _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "foo bar baz",
+ "--playbook-file", pbPath,
+ "--notes", "original note",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ if _, _, err := runRootArgs(t,
+ "playbook", "amend",
+ "--query", "foo bar baz",
+ "--add-note", "a new correction",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("amend: %v", err)
+ }
+
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := s.ListPlaybooks()
+ if len(rows) != 1 {
+ t.Fatalf("want 1 row (amend should append, not create new), got %d", len(rows))
+ }
+ if !strings.Contains(rows[0].NotesText, "original note") {
+ t.Errorf("amend should preserve original notes; got %q", rows[0].NotesText)
+ }
+ if !strings.Contains(rows[0].NotesText, "a new correction") {
+ t.Errorf("amend should add new note; got %q", rows[0].NotesText)
+ }
+ if !strings.Contains(rows[0].NotesText, "[amend ") {
+ t.Errorf("amend should include timestamp marker; got %q", rows[0].NotesText)
+ }
+}
+
+func TestPlaybookAmend_EmptyFamily_CreatesNotesOnly(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ if _, _, err := runRootArgs(t,
+ "playbook", "amend",
+ "--query", "brand new query family",
+ "--add-note", "from cold",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("amend: %v", err)
+ }
+
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := s.ListPlaybooks()
+ if len(rows) != 1 {
+ t.Fatalf("want 1 row created from cold amend, got %d", len(rows))
+ }
+ if !strings.Contains(rows[0].NotesText, "from cold") {
+ t.Errorf("notes should contain the amend text; got %q", rows[0].NotesText)
+ }
+ if !strings.Contains(rows[0].NotesText, "[amend ") {
+ t.Errorf("cold amend should still carry the timestamp marker; got %q", rows[0].NotesText)
+ }
+}
+
+func TestPlaybookAmend_RequiresQuery(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "playbook", "amend",
+ "--add-note", "missing query",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("amend without --query should exit nonzero")
+ }
+}
+
+func TestPlaybookAmend_RequiresAddNote(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "playbook", "amend",
+ "--query", "no add-note",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("amend without --add-note should exit nonzero")
+ }
+}
+
+func TestPlaybookAmend_MultipleAmendsTimestamped(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ for i, text := range []string{"first amend", "second amend", "third amend"} {
+ if _, _, err := runRootArgs(t,
+ "playbook", "amend",
+ "--query", "multi-amend test",
+ "--add-note", text,
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("amend %d: %v", i, err)
+ }
+ }
+
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := s.ListPlaybooks()
+ if len(rows) != 1 {
+ t.Fatalf("want 1 row after 3 amends to same family, got %d", len(rows))
+ }
+ for _, text := range []string{"first amend", "second amend", "third amend"} {
+ if !strings.Contains(rows[0].NotesText, text) {
+ t.Errorf("notes missing %q; full: %q", text, rows[0].NotesText)
+ }
+ }
+}
+
+func TestPlaybookAmend_RespectsNoLearn(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ t.Setenv(noLearnEnvVar, "true")
+
+ _, _, err := runRootArgs(t,
+ "playbook", "amend",
+ "--query", "should noop",
+ "--add-note", "should noop",
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("amend with NO_LEARN should be silent noop: %v", err)
+ }
+
+ if _, statErr := os.Stat(dbPath); statErr == nil {
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := s.ListPlaybooks()
+ if len(rows) != 0 {
+ t.Errorf("NO_LEARN should suppress amend writes; got %d rows", len(rows))
+ }
+ }
+}
+
+func TestPlaybookList_Empty(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ stdout, _, err := runRootArgs(t,
+ "playbook", "list",
+ "--db", dbPath,
+ "--json",
+ )
+ if err != nil {
+ t.Fatalf("playbook list: %v", err)
+ }
+ if strings.TrimSpace(stdout) != "[]" {
+ t.Errorf("empty list should be []; got %q", stdout)
+ }
+}
+
+func TestPlaybookList_WithRows(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ pbPath := writePlaybookFile(t, home, "p.json", `{"steps":[{"cmd":"x"}]}`)
+
+ if _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "first query",
+ "--playbook-file", pbPath,
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("seed first: %v", err)
+ }
+ if _, _, err := runRootArgs(t,
+ "teach-playbook",
+ "--query", "second query",
+ "--notes", "remember the thing",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("seed second: %v", err)
+ }
+
+ stdout, _, err := runRootArgs(t,
+ "playbook", "list",
+ "--db", dbPath,
+ "--json",
+ )
+ if err != nil {
+ t.Fatalf("playbook list: %v", err)
+ }
+ var rows []map[string]any
+ if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
+ t.Fatalf("decode list: %v (stdout=%q)", err, stdout)
+ }
+ if len(rows) != 2 {
+ t.Errorf("want 2 rows, got %d", len(rows))
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/teach_test.go b/library/productivity/human-goat/internal/cli/teach_test.go
new file mode 100644
index 0000000000..401bf14ef0
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/teach_test.go
@@ -0,0 +1,1215 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+func unmarshalAgentResults(t *testing.T, stdout string, out any) {
+ t.Helper()
+ var envelope struct {
+ Meta map[string]any `json:"meta"`
+ Results json.RawMessage `json:"results"`
+ }
+ if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
+ t.Fatalf("agent envelope JSON: %v (stdout=%q)", err, stdout)
+ }
+ if src, _ := envelope.Meta["source"].(string); src == "" {
+ t.Fatalf("agent envelope missing meta.source: %q", stdout)
+ }
+ if len(envelope.Results) == 0 {
+ t.Fatalf("agent envelope missing results: %q", stdout)
+ }
+ if err := json.Unmarshal(envelope.Results, out); err != nil {
+ t.Fatalf("agent envelope results JSON: %v (stdout=%q)", err, stdout)
+ }
+}
+
+// withTempLearnHome points HOME at a fresh temp dir and clears the
+// per-CLI no-learn env var so every test gets the default "learning
+// on" behavior. Returns the temp HOME so callers can resolve the
+// per-cli state dir.
+func withTempLearnHome(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ t.Setenv("HOME", dir)
+ t.Setenv(noLearnEnvVar, "")
+ t.Setenv("HUMAN_GOAT_STATE_DIR", "")
+ t.Setenv("HUMAN_GOAT_HOME", "")
+ t.Setenv("XDG_STATE_HOME", "")
+ return dir
+}
+
+// runRootArgs executes the CLI with the supplied args, returning
+// stdout, stderr, and the error from rootCmd.Execute. The test
+// harness routes both streams through bytes.Buffer.
+func runRootArgs(t *testing.T, args ...string) (string, string, error) {
+ t.Helper()
+ rootCmd := RootCmd()
+ var stdout, stderr bytes.Buffer
+ rootCmd.SetOut(&stdout)
+ rootCmd.SetErr(&stderr)
+ rootCmd.SetArgs(args)
+ err := rootCmd.Execute()
+ return stdout.String(), stderr.String(), err
+}
+
+func TestTeachCommand_SilentOnSuccess(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ stdout, stderr, err := runRootArgs(t,
+ "teach",
+ "--query", "find all widgets in inventory",
+ "--resource", "widget-42",
+ "--resource-type", "items",
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach exited non-zero: %v (stderr=%q)", err, stderr)
+ }
+ if stdout != "" {
+ t.Errorf("teach should be silent on success; stdout=%q", stdout)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen db: %v", err)
+ }
+ defer s.Close()
+ rows, err := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ if err != nil {
+ t.Fatalf("listLearnings: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("expected one row, got %d (%#v)", len(rows), rows)
+ }
+ if rows[0].ResourceID != "widget-42" {
+ t.Errorf("expected resource_id widget-42, got %q", rows[0].ResourceID)
+ }
+}
+
+func TestTeachCommand_MissingResourceTypeErrors(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ stdout, _, err := runRootArgs(t,
+ "teach",
+ "--query", "find all widgets",
+ "--resource", "widget-42",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("teach without --resource-type should exit non-zero")
+ }
+ if stdout != "" {
+ t.Errorf("error stdout should be empty; got %q", stdout)
+ }
+
+ // teach.log should exist with a missing --resource-type line.
+ stateDir, stateErr := cliutil.StateDir()
+ if stateErr != nil {
+ t.Fatalf("StateDir() error = %v", stateErr)
+ }
+ logPath := filepath.Join(stateDir, teachErrLogFileName)
+ data, statErr := os.ReadFile(logPath)
+ if statErr != nil {
+ t.Fatalf("teach.log should exist on error: %v", statErr)
+ }
+ if !strings.Contains(string(data), "missing --resource-type") {
+ t.Errorf("teach.log should record the failure; got %q", string(data))
+ }
+}
+
+func TestTeachCommand_MissingResourceErrors(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "teach",
+ "--query", "find all widgets",
+ "--resource-type", "items",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("teach without --resource should exit non-zero")
+ }
+}
+
+func TestTeachCommand_RespectsNoLearnEnvVar(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ t.Setenv(noLearnEnvVar, "true")
+
+ stdout, stderr, err := runRootArgs(t,
+ "teach",
+ "--query", "find all widgets",
+ "--resource", "widget-42",
+ "--resource-type", "items",
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach with NO_LEARN should be silent no-op; err=%v stderr=%q", err, stderr)
+ }
+ if stdout != "" || stderr != "" {
+ t.Errorf("NO_LEARN should be silent; got stdout=%q stderr=%q", stdout, stderr)
+ }
+
+ // DB file should not exist — teach exited before opening.
+ if _, statErr := os.Stat(dbPath); statErr == nil {
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ if len(rows) != 0 {
+ t.Errorf("NO_LEARN should leave DB empty; got %d rows", len(rows))
+ }
+ }
+}
+
+func TestRecallCommand_FoundAndNotFound(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ if _, _, err := runRootArgs(t,
+ "teach",
+ "--query", "find all widgets in inventory",
+ "--resource", "widget-42",
+ "--resource", "widget-43",
+ "--resource-type", "items",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("seed teach: %v", err)
+ }
+
+ stdout, _, err := runRootArgs(t,
+ "recall", "find all widgets in inventory",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ var env recallEnvelope
+ unmarshalAgentResults(t, stdout, &env)
+ if !env.Found || len(env.Results) != 2 {
+ t.Errorf("recall: want found+2 results, got %+v", env)
+ }
+
+ stdout, _, err = runRootArgs(t,
+ "recall", "completely unrelated query",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("recall unrelated: %v", err)
+ }
+ env = recallEnvelope{}
+ unmarshalAgentResults(t, stdout, &env)
+ if env.Found || len(env.Results) != 0 {
+ t.Errorf("unrelated recall: want empty, got %+v", env)
+ }
+}
+
+func TestRecallEnvelopeResultsCarryAliasTarget(t *testing.T) {
+ got := toEnvelopeResults([]learn.Hit{
+ {
+ ResourceID: "old-id",
+ Action: store.LearningActionAlias,
+ AliasTarget: "new-id",
+ Confidence: 3,
+ MatchScore: 1,
+ EntityMatch: learn.EntityMatchExact,
+ ResourceType: "items",
+ },
+ })
+ if len(got) != 1 {
+ t.Fatalf("want 1 result, got %d", len(got))
+ }
+ if got[0].AliasTarget != "new-id" {
+ t.Fatalf("AliasTarget = %q, want new-id", got[0].AliasTarget)
+ }
+}
+
+func TestRecallCommand_RespectsNoLearnEnvVar(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ if _, _, err := runRootArgs(t,
+ "teach",
+ "--query", "find all widgets",
+ "--resource", "widget-42",
+ "--resource-type", "items",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ t.Setenv(noLearnEnvVar, "true")
+ stdout, _, err := runRootArgs(t,
+ "recall", "find all widgets",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("recall with NO_LEARN: %v", err)
+ }
+ var env recallEnvelope
+ unmarshalAgentResults(t, stdout, &env)
+ if env.Found {
+ t.Errorf("NO_LEARN should suppress recall results; got %+v", env)
+ }
+}
+
+func TestLearningsList_FiltersByQuery(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ if _, _, err := runRootArgs(t,
+ "teach", "--query", "find all widgets", "--resource", "A",
+ "--resource-type", "items", "--db", dbPath); err != nil {
+ t.Fatalf("seed A: %v", err)
+ }
+ if _, _, err := runRootArgs(t,
+ "teach", "--query", "list todays orders", "--resource", "B",
+ "--resource-type", "orders", "--db", dbPath); err != nil {
+ t.Fatalf("seed B: %v", err)
+ }
+
+ // --json so the row shape isn't stripped by the agent-allow-list
+ // pass in printJSONFiltered.
+ stdout, _, err := runRootArgs(t,
+ "learnings", "list",
+ "--query", "widget",
+ "--db", dbPath,
+ "--json",
+ )
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ var rows []learningRow
+ if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
+ t.Fatalf("list JSON: %v (stdout=%q)", err, stdout)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("list filter: want 1 row, got %d (%+v)", len(rows), rows)
+ }
+ if rows[0].ResourceID != "A" {
+ t.Errorf("expected resource A in filtered row, got %q", rows[0].ResourceID)
+ }
+}
+
+func TestLearningsForget_TargetedAndAll(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ for _, rid := range []string{"X", "Y", "Z"} {
+ if _, _, err := runRootArgs(t,
+ "teach", "--query", "find all widgets",
+ "--resource", rid,
+ "--resource-type", "items",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("seed %s: %v", rid, err)
+ }
+ }
+
+ _, _, err := runRootArgs(t,
+ "learnings", "forget", "find all widgets",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Errorf("forget without filter should error")
+ }
+
+ stdout, _, err := runRootArgs(t,
+ "learnings", "forget", "find all widgets",
+ "--resource", "Y",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("forget Y: %v", err)
+ }
+ var resp map[string]any
+ unmarshalAgentResults(t, stdout, &resp)
+ if v, _ := resp["deleted"].(float64); v != 1 {
+ t.Errorf("forget Y: want deleted=1, got %v", resp["deleted"])
+ }
+
+ stdout, _, err = runRootArgs(t,
+ "learnings", "forget", "find all widgets",
+ "--all",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("forget all: %v", err)
+ }
+ resp = nil
+ unmarshalAgentResults(t, stdout, &resp)
+ if v, _ := resp["deleted"].(float64); v < 1 {
+ t.Errorf("forget all: want deleted>=1, got %v", resp["deleted"])
+ }
+}
+
+func TestTeachPattern_HappyPath(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ stdout, _, err := runRootArgs(t,
+ "teach-pattern",
+ "--query-template", "show me {entity} stats",
+ "--resource-template", "STATS-{entity:team}",
+ "--resource-type", "teams",
+ "--entity-kind", "team",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("teach-pattern: %v", err)
+ }
+ var resp map[string]any
+ unmarshalAgentResults(t, stdout, &resp)
+ if v, _ := resp["recorded"].(bool); !v {
+ t.Errorf("teach-pattern: want recorded=true, got %v", resp)
+ }
+}
+
+func TestTeachPattern_RequiresFlags(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "teach-pattern",
+ "--query-template", "show me {entity} stats",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("teach-pattern without --resource-template / --resource-type / --entity-kind should error")
+ }
+}
+
+func TestTeachLookup_HappyPath(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ stdout, _, err := runRootArgs(t,
+ "teach-lookup",
+ "--kind", "country",
+ "--canonical", "United States",
+ "--value", "USA",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("teach-lookup: %v", err)
+ }
+ var resp map[string]any
+ unmarshalAgentResults(t, stdout, &resp)
+ if v, _ := resp["recorded"].(bool); !v {
+ t.Errorf("teach-lookup: want recorded=true, got %v", resp)
+ }
+}
+
+func TestTeachLookup_RejectsComputedKind(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "teach-lookup",
+ "--kind", "lowercase",
+ "--canonical", "Foo",
+ "--alias", "foo",
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("teach-lookup with computed kind should error")
+ }
+}
+
+func TestSkipLearnHook_FrameworkCommandsSkipped(t *testing.T) {
+ cases := []string{
+ "human-goat-pp-cli auth login",
+ "human-goat-pp-cli doctor",
+ "human-goat-pp-cli help items",
+ "human-goat-pp-cli sync",
+ "human-goat-pp-cli profile list",
+ "human-goat-pp-cli feedback list",
+ "human-goat-pp-cli which",
+ "human-goat-pp-cli agent-context",
+ "human-goat-pp-cli completion bash",
+ "human-goat-pp-cli version",
+ }
+ for _, commandPath := range cases {
+ if !shouldSkipLearnHook(commandPath) {
+ t.Errorf("shouldSkipLearnHook(%q) = false, want true", commandPath)
+ }
+ }
+}
+
+func TestSkipLearnHook_NovelCommandsNotSkipped(t *testing.T) {
+ novel := []string{
+ "human-goat-pp-cli teach",
+ "human-goat-pp-cli recall",
+ "human-goat-pp-cli learnings",
+ "human-goat-pp-cli teach-pattern",
+ "human-goat-pp-cli teach-lookup",
+ "human-goat-pp-cli items list",
+ }
+ for _, commandPath := range novel {
+ if shouldSkipLearnHook(commandPath) {
+ t.Errorf("shouldSkipLearnHook(%q) = true, want false", commandPath)
+ }
+ }
+}
+
+func TestSkipLearnHook_FrameworkCommandsDoNotCreateDefaultDB(t *testing.T) {
+ home := withTempLearnHome(t)
+ learnInitOnce = sync.Once{}
+
+ cases := [][]string{
+ {"auth", "setup"},
+ {"completion", "bash"},
+ {"version"},
+ {"help", "items"},
+ {"doctor"},
+ }
+ for _, args := range cases {
+ _, _, _ = runRootArgs(t, args...)
+ if _, err := os.Stat(defaultDBPath("human-goat-pp-cli")); err == nil {
+ t.Fatalf("%s created default DB under %s", strings.Join(args, " "), home)
+ } else if !os.IsNotExist(err) {
+ t.Fatalf("%s stat default DB: %v", strings.Join(args, " "), err)
+ }
+ }
+}
+
+// TestRecallCommand_EnvelopeCarriesQueryEntities is a smoke test that
+// the cold-recall envelope still includes the query_entities slice so
+// agents can introspect what the CLI extracted.
+func TestRecallCommand_EnvelopeCarriesQueryEntities(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ stdout, _, err := runRootArgs(t,
+ "recall", "what is the status of widget-42",
+ "--db", dbPath,
+ "--agent",
+ )
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ var env recallEnvelope
+ unmarshalAgentResults(t, stdout, &env)
+ if env.Normalized == "" {
+ t.Errorf("cold recall should still populate normalized; got %q", env.Normalized)
+ }
+ if env.QueryEntities == nil {
+ t.Errorf("cold recall should populate query_entities as a non-nil slice")
+ }
+}
+
+// Defense: assert the per-CLI entity Config the tests use is non-nil
+// by referencing the package so the unused-import lint doesn't fire
+// when the suite shrinks.
+var _ = entities.NewConfig
+
+// TestTeachCommand_IntegratedPlaybookFile covers R3: a single
+// `teach --query --resource --playbook-file --playbook-notes-file`
+// call records both the resource learning AND the playbook row in
+// one shot, so end-of-session flows don't need to fire two CLIs.
+func TestTeachCommand_IntegratedPlaybookFile(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ pbPath := writePlaybookFile(t, home, "pb.json",
+ `{"steps":[{"cmd":"items list {category.id}"}],"entity_slots":["$CATEGORY"]}`)
+ notesPath := writePlaybookFile(t, home, "notes.md",
+ "the items-by-source endpoint needs source_type=2; duplicate labels")
+
+ stdout, stderr, err := runRootArgs(t,
+ "teach",
+ "--query", "list all widgets in inventory and their top stats",
+ "--resource", "widget-42",
+ "--resource-type", "items",
+ "--playbook-file", pbPath,
+ "--playbook-notes-file", notesPath,
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach integrated: %v (stderr=%q)", err, stderr)
+ }
+ if stdout != "" {
+ t.Errorf("teach should be silent on success; stdout=%q", stdout)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen db: %v", err)
+ }
+ defer s.Close()
+
+ // Resource learning landed.
+ rows, err := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ if err != nil {
+ t.Fatalf("listLearnings: %v", err)
+ }
+ if len(rows) != 1 || rows[0].ResourceID != "widget-42" {
+ t.Fatalf("expected 1 learning for widget-42, got %#v", rows)
+ }
+
+ // Playbook row landed in the same call.
+ pb, err := s.ListPlaybooks()
+ if err != nil {
+ t.Fatalf("ListPlaybooks: %v", err)
+ }
+ if len(pb) != 1 {
+ t.Fatalf("expected 1 playbook row, got %d", len(pb))
+ }
+ if !strings.Contains(pb[0].PlaybookJSON, "items list") {
+ t.Errorf("playbook_json not stored: %q", pb[0].PlaybookJSON)
+ }
+ if !strings.Contains(pb[0].NotesText, "source_type=2") {
+ t.Errorf("notes_text not stored: %q", pb[0].NotesText)
+ }
+}
+
+// TestTeachCommand_IntegratedNoPlaybookFlags confirms the playbook
+// upsert is a no-op when none of the playbook flags is set -- the
+// resource learning still lands, no playbook row is written, and the
+// command exits 0.
+func TestTeachCommand_IntegratedNoPlaybookFlags(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, _, err := runRootArgs(t,
+ "teach",
+ "--query", "find all widgets",
+ "--resource", "widget-1",
+ "--resource-type", "items",
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer s.Close()
+ rows, _ := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 learning, got %d", len(rows))
+ }
+ pb, _ := s.ListPlaybooks()
+ if len(pb) != 0 {
+ t.Errorf("expected 0 playbooks (no flags set), got %d", len(pb))
+ }
+}
+
+// TestTeachCommand_IntegratedPlaybookFileOnly covers the variant
+// where only --playbook-file is set: playbook lands, notes_text is
+// empty, resource learning still lands.
+func TestTeachCommand_IntegratedPlaybookFileOnly(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ pbPath := writePlaybookFile(t, home, "pb.json",
+ `{"steps":[{"cmd":"items list"}]}`)
+
+ _, _, err := runRootArgs(t,
+ "teach",
+ "--query", "list widgets again",
+ "--resource", "widget-7",
+ "--resource-type", "items",
+ "--playbook-file", pbPath,
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ pb, _ := s.ListPlaybooks()
+ if len(pb) != 1 {
+ t.Fatalf("expected 1 playbook, got %d", len(pb))
+ }
+ if pb[0].PlaybookJSON == "" {
+ t.Errorf("playbook_json should be non-empty; got %q", pb[0].PlaybookJSON)
+ }
+ if pb[0].NotesText != "" {
+ t.Errorf("notes_text should be empty when only --playbook-file set; got %q", pb[0].NotesText)
+ }
+}
+
+// TestTeachCommand_IntegratedNotesFileOnly covers the variant where
+// only --playbook-notes-file is set: playbook row lands with empty
+// playbook_json and populated notes_text.
+func TestTeachCommand_IntegratedNotesFileOnly(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ notesPath := writePlaybookFile(t, home, "notes.md", "remember to set source_type")
+
+ _, _, err := runRootArgs(t,
+ "teach",
+ "--query", "yet another widget query",
+ "--resource", "widget-9",
+ "--resource-type", "items",
+ "--playbook-notes-file", notesPath,
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ pb, _ := s.ListPlaybooks()
+ if len(pb) != 1 {
+ t.Fatalf("expected 1 playbook, got %d", len(pb))
+ }
+ if pb[0].PlaybookJSON != "" {
+ t.Errorf("playbook_json should be empty when only --playbook-notes-file set; got %q", pb[0].PlaybookJSON)
+ }
+ if !strings.Contains(pb[0].NotesText, "source_type") {
+ t.Errorf("notes_text content missing: %q", pb[0].NotesText)
+ }
+}
+
+// learnEventRow is the readback shape the event-instrumentation tests
+// assert against.
+type learnEventRow struct {
+ Event string
+ FamilyHash string
+ MatchedRowID int64
+ EntityMatch int
+ Surface string
+}
+
+// readLearnEvents returns every learn_events row (insert order) from
+// the given DB, opening and closing its own store handle so the CLI
+// under test owns the file while running.
+func readLearnEvents(t *testing.T, dbPath string) []learnEventRow {
+ t.Helper()
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store for events: %v", err)
+ }
+ defer s.Close()
+ rows, err := s.DB().Query(`SELECT event, COALESCE(query_family_hash, ''),
+ COALESCE(matched_row_id, 0), COALESCE(entity_match, 0), COALESCE(surface, '')
+ FROM learn_events ORDER BY id ASC`)
+ if err != nil {
+ t.Fatalf("read events: %v", err)
+ }
+ defer rows.Close()
+ var out []learnEventRow
+ for rows.Next() {
+ var r learnEventRow
+ if err := rows.Scan(&r.Event, &r.FamilyHash, &r.MatchedRowID, &r.EntityMatch, &r.Surface); err != nil {
+ t.Fatalf("scan event: %v", err)
+ }
+ out = append(out, r)
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("events rows: %v", err)
+ }
+ return out
+}
+
+// filterLearnEvents returns the subset with the given event type.
+func filterLearnEvents(rows []learnEventRow, event string) []learnEventRow {
+ var out []learnEventRow
+ for _, r := range rows {
+ if r.Event == event {
+ out = append(out, r)
+ }
+ }
+ return out
+}
+
+// TestLearnEvents_MissTeachHitSequenceRowIDLinkage drives the
+// canonical loop end to end — cold recall, teach, warm recall — and
+// pins the three correctly-typed events with row-id linkage: the
+// teach event and the recall_hit both carry the taught learning's
+// search_learnings row id, so teach-to-reuse joins by row id.
+func TestLearnEvents_MissTeachHitSequenceRowIDLinkage(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ const query = "find all widgets in inventory"
+
+ if _, _, err := runRootArgs(t, "recall", query, "--db", dbPath, "--agent"); err != nil {
+ t.Fatalf("cold recall: %v", err)
+ }
+ if _, _, err := runRootArgs(t,
+ "teach", "--query", query,
+ "--resource", "widget-42", "--resource-type", "items",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+ if _, _, err := runRootArgs(t, "recall", query, "--db", dbPath, "--agent"); err != nil {
+ t.Fatalf("warm recall: %v", err)
+ }
+
+ // The taught learning's row id anchors the linkage assertions.
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ rows, err := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ s.Close()
+ if err != nil || len(rows) != 1 {
+ t.Fatalf("want exactly 1 learning row (err=%v), got %d", err, len(rows))
+ }
+ taughtID := rows[0].ID
+
+ events := readLearnEvents(t, dbPath)
+ misses := filterLearnEvents(events, store.LearnEventRecallMiss)
+ teaches := filterLearnEvents(events, store.LearnEventTeach)
+ hits := filterLearnEvents(events, store.LearnEventRecallHit)
+ if len(misses) != 1 || len(teaches) != 1 || len(hits) != 1 {
+ t.Fatalf("want 1 miss / 1 teach / 1 hit, got %d/%d/%d (%+v)",
+ len(misses), len(teaches), len(hits), events)
+ }
+ if teaches[0].MatchedRowID != taughtID {
+ t.Errorf("teach event row id = %d, want taught row %d", teaches[0].MatchedRowID, taughtID)
+ }
+ if hits[0].MatchedRowID != taughtID {
+ t.Errorf("recall_hit event row id = %d, want taught row %d", hits[0].MatchedRowID, taughtID)
+ }
+ if misses[0].FamilyHash == "" || misses[0].FamilyHash != teaches[0].FamilyHash {
+ t.Errorf("miss family hash %q should match teach family hash %q",
+ misses[0].FamilyHash, teaches[0].FamilyHash)
+ }
+ for _, r := range events {
+ if r.Surface != store.LearnSurfaceCLI {
+ t.Errorf("event %s surface = %q, want cli", r.Event, r.Surface)
+ }
+ }
+}
+
+// TestLearnEvents_AliasMediatedRecallCreditsTaughtRowID pins the
+// row-id join rationale: a recall phrased through a different alias
+// (and different structural tokens) lands under a DIFFERENT family
+// hash than the teach, so a family-hash-only join would miss the
+// reuse — but the recall_hit's matched_row_id still credits the
+// taught row, and stats report teach-to-reuse = 1.
+func TestLearnEvents_AliasMediatedRecallCreditsTaughtRowID(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ // Register the alias pair so the cross-alias canonical resolver
+ // can bridge the two phrasings.
+ for _, value := range []string{"WidgetCo", "WC"} {
+ if _, _, err := runRootArgs(t,
+ "teach-lookup", "--kind", "org",
+ "--canonical", "WidgetCo", "--value", value,
+ "--db", dbPath, "--agent",
+ ); err != nil {
+ t.Fatalf("teach-lookup %s: %v", value, err)
+ }
+ }
+
+ if _, _, err := runRootArgs(t,
+ "teach", "--query", "show quarterly report for WidgetCo",
+ "--resource", "report-7", "--resource-type", "reports",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+
+ stdout, _, err := runRootArgs(t,
+ "recall", "display quarterly report for WC",
+ "--db", dbPath, "--agent",
+ )
+ if err != nil {
+ t.Fatalf("alias recall: %v", err)
+ }
+ var env recallEnvelope
+ unmarshalAgentResults(t, stdout, &env)
+ if !env.Found {
+ t.Fatalf("alias-mediated recall should hit; got %+v", env)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ rows, err := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ if err != nil || len(rows) != 1 {
+ s.Close()
+ t.Fatalf("want 1 learning row (err=%v), got %d", err, len(rows))
+ }
+ taughtID := rows[0].ID
+
+ events := readLearnEvents(t, dbPath)
+ teaches := filterLearnEvents(events, store.LearnEventTeach)
+ hits := filterLearnEvents(events, store.LearnEventRecallHit)
+ if len(teaches) != 1 || len(hits) != 1 {
+ s.Close()
+ t.Fatalf("want 1 teach / 1 hit, got %d/%d (%+v)", len(teaches), len(hits), events)
+ }
+ if hits[0].MatchedRowID != taughtID {
+ t.Errorf("recall_hit row id = %d, want taught row %d", hits[0].MatchedRowID, taughtID)
+ }
+ // The pin: the family hashes DIFFER (a family-hash-only join would
+ // miss this reuse) yet the row-id join credits it.
+ if hits[0].FamilyHash == teaches[0].FamilyHash {
+ t.Errorf("alias recall family hash %q should differ from teach family hash %q — the scenario must exercise the row-id join",
+ hits[0].FamilyHash, teaches[0].FamilyHash)
+ }
+ stats, err := s.LearnEventStats(context.Background())
+ s.Close()
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if stats.TaughtRows != 1 || stats.ReusedRows != 1 {
+ t.Errorf("taught/reused = %d/%d, want 1/1 via row-id join", stats.TaughtRows, stats.ReusedRows)
+ }
+}
+
+// TestLearnEvents_InsertFailureNeverFailsRecall pins the best-effort
+// contract at the helper seam: recordRecallEvents against a closed
+// store must swallow the failure (to teach.log) — recall's result was
+// already computed and must reach the agent.
+func TestLearnEvents_InsertFailureNeverFailsRecall(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ s.Close() // every insert from here on fails
+
+ result := learn.Result{
+ Query: "some question",
+ Family: "question some",
+ Found: true,
+ Results: []learn.Hit{
+ {
+ LearningID: 1,
+ ResourceID: "widget-1",
+ EntityMatch: learn.EntityMatchExact,
+ },
+ },
+ Playbook: &learn.ResolvedPlaybook{QueryFamily: "question some"},
+ }
+ // Must not panic and must not propagate an error (void contract).
+ recordRecallEvents(s, result)
+ recordRecallEvents(nil, result)
+
+ // The failure is diagnosable in teach.log.
+ stateDir, err := cliutil.StateDir()
+ if err != nil {
+ t.Fatalf("StateDir: %v", err)
+ }
+ data, err := os.ReadFile(filepath.Join(stateDir, teachErrLogFileName))
+ if err != nil {
+ t.Fatalf("teach.log should record the insert failure: %v", err)
+ }
+ if !strings.Contains(string(data), "event insert") {
+ t.Errorf("teach.log should mention the event insert; got %q", string(data))
+ }
+}
+
+// TestLearnEvents_ConcurrentTeachRecallLosesNothing drives the event
+// layer from two concurrent writers on two separate store handles to
+// the same file — the shape of a backgrounded teach racing a recall
+// in another process — and asserts every event lands. Run with -race
+// by the generator harness. (The full cobra commands are exercised
+// per-invocation elsewhere; the generated root binds process-global
+// flags, so in-process concurrency is only meaningful below the
+// command layer.)
+func TestLearnEvents_ConcurrentTeachRecallLosesNothing(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ teachStore, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open teach store: %v", err)
+ }
+ defer teachStore.Close()
+ recallStore, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open recall store: %v", err)
+ }
+ defer recallStore.Close()
+
+ const rounds = 20
+ var wg sync.WaitGroup
+ errCh := make(chan error, rounds)
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < rounds; i++ {
+ if err := teachStore.InsertLearnEvent(store.LearnEventTeach,
+ "fam-conc", int64(i+1), false, store.LearnSurfaceCLI); err != nil {
+ errCh <- err
+ }
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ for i := 0; i < rounds; i++ {
+ // recordRecallEvents is the command side's insert path;
+ // a hit result with a playbook writes two events.
+ recordRecallEvents(recallStore, learn.Result{
+ Family: "fam-conc",
+ Found: true,
+ Results: []learn.Hit{
+ {LearningID: int64(i + 1), EntityMatch: learn.EntityMatchExact},
+ },
+ })
+ }
+ }()
+ wg.Wait()
+ close(errCh)
+ for err := range errCh {
+ t.Errorf("concurrent teach event: %v", err)
+ }
+
+ events := readLearnEvents(t, dbPath)
+ if got := len(filterLearnEvents(events, store.LearnEventTeach)); got != rounds {
+ t.Errorf("teach events = %d, want %d", got, rounds)
+ }
+ if got := len(filterLearnEvents(events, store.LearnEventRecallHit)); got != rounds {
+ t.Errorf("recall_hit events = %d, want %d (best-effort inserts must not silently drop under concurrency)", got, rounds)
+ }
+}
+
+// TestLearnEvents_ForgetCascadesFamilyEvents pins the forget half of
+// R18: forgetting a query removes the learning AND its measurement
+// trail (by family hash), leaving only the forget event itself.
+func TestLearnEvents_ForgetCascadesFamilyEvents(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ const query = "find all widgets in inventory"
+
+ if _, _, err := runRootArgs(t,
+ "teach", "--query", query,
+ "--resource", "widget-42", "--resource-type", "items",
+ "--db", dbPath,
+ ); err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+ if _, _, err := runRootArgs(t, "recall", query, "--db", dbPath, "--agent"); err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got := len(readLearnEvents(t, dbPath)); got != 2 {
+ t.Fatalf("precondition: want 2 events (teach + hit), got %d", got)
+ }
+
+ if _, _, err := runRootArgs(t,
+ "learnings", "forget", query, "--all", "--db", dbPath, "--agent",
+ ); err != nil {
+ t.Fatalf("forget: %v", err)
+ }
+
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ rows, err := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ s.Close()
+ if err != nil {
+ t.Fatalf("listLearnings: %v", err)
+ }
+ if len(rows) != 0 {
+ t.Errorf("learning rows after forget = %d, want 0", len(rows))
+ }
+
+ events := readLearnEvents(t, dbPath)
+ if len(events) != 1 || events[0].Event != store.LearnEventForget {
+ t.Errorf("events after forget = %+v, want exactly one forget event", events)
+ }
+}
+
+// TestTeachCommand_PIIEmailWarnsAndSucceeds pins the R18 insert-time
+// guard: a teach whose query carries an email shape warns to stderr
+// naming the PII rule, and the teach still lands (never blocked,
+// never silently redacted).
+func TestTeachCommand_PIIEmailWarnsAndSucceeds(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ stdout, stderr, err := runRootArgs(t,
+ "teach",
+ "--query", "orders placed by jane.doe@example.com last week",
+ "--resource", "order-1", "--resource-type", "orders",
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach with PII should still succeed: %v", err)
+ }
+ if stdout != "" {
+ t.Errorf("teach stays silent on stdout; got %q", stdout)
+ }
+ if !strings.Contains(stderr, "email") || !strings.Contains(stderr, "PII") {
+ t.Errorf("stderr should name the email PII rule; got %q", stderr)
+ }
+
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ if len(rows) != 1 {
+ t.Errorf("the learning must still land (warn-only); got %d rows", len(rows))
+ }
+}
+
+// TestTeachCommand_NoPIINoWarning guards the conservative side: an
+// ordinary structural query with a numeric identifier produces no PII
+// warning.
+func TestTeachCommand_NoPIINoWarning(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, stderr, err := runRootArgs(t,
+ "teach",
+ "--query", "status of order 5551234567 in the queue",
+ "--resource", "order-2", "--resource-type", "orders",
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+ if strings.Contains(stderr, "PII") {
+ t.Errorf("bare numeric id must not trip the phone rule; stderr=%q", stderr)
+ }
+}
+
+// TestTeachCommand_PlaybookJSONInline covers the inline flag: a teach
+// with --playbook-json and no file records both the resource learning
+// and the playbook row.
+func TestTeachCommand_PlaybookJSONInline(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+
+ _, stderr, err := runRootArgs(t,
+ "teach",
+ "--query", "list all widgets and their top stats",
+ "--resource", "widget-42", "--resource-type", "items",
+ "--playbook-json", `{"steps":[{"cmd":"items list {category.id}"}]}`,
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach --playbook-json: %v (stderr=%q)", err, stderr)
+ }
+
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 learning, got %d", len(rows))
+ }
+ pb, _ := s.ListPlaybooks()
+ if len(pb) != 1 {
+ t.Fatalf("expected 1 playbook from inline JSON, got %d", len(pb))
+ }
+ if !strings.Contains(pb[0].PlaybookJSON, "items list") {
+ t.Errorf("inline playbook_json not stored: %q", pb[0].PlaybookJSON)
+ }
+}
+
+// TestTeachCommand_PlaybookJSONAndFileMutuallyExclusive pins the
+// usage contract: both flags together is a (silent, typed-exit-2)
+// usage error recorded in teach.log, and nothing lands.
+func TestTeachCommand_PlaybookJSONAndFileMutuallyExclusive(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ pbPath := writePlaybookFile(t, home, "pb.json", `{"steps":[{"cmd":"items list"}]}`)
+
+ stdout, _, err := runRootArgs(t,
+ "teach",
+ "--query", "both playbook inputs",
+ "--resource", "widget-1", "--resource-type", "items",
+ "--playbook-file", pbPath,
+ "--playbook-json", `{"steps":[{"cmd":"items list"}]}`,
+ "--db", dbPath,
+ )
+ if err == nil {
+ t.Fatal("teach with both --playbook-file and --playbook-json should error")
+ }
+ if stdout != "" {
+ t.Errorf("error path stays silent on stdout; got %q", stdout)
+ }
+
+ stateDir, stateErr := cliutil.StateDir()
+ if stateErr != nil {
+ t.Fatalf("StateDir: %v", stateErr)
+ }
+ data, readErr := os.ReadFile(filepath.Join(stateDir, teachErrLogFileName))
+ if readErr != nil {
+ t.Fatalf("teach.log should exist: %v", readErr)
+ }
+ if !strings.Contains(string(data), "mutually exclusive") {
+ t.Errorf("teach.log should record the mutual exclusion; got %q", string(data))
+ }
+}
+
+// TestTeachCommand_IntegratedPlaybookErrorDegrades confirms that a
+// failing playbook input (malformed JSON) does NOT prevent the
+// resource learning from landing. Per design, playbook failures log
+// to teach.log; the command still exits 0 because the agent's
+// primary write (resource learning) succeeded.
+func TestTeachCommand_IntegratedPlaybookErrorDegrades(t *testing.T) {
+ home := withTempLearnHome(t)
+ dbPath := filepath.Join(home, "data.db")
+ badPath := writePlaybookFile(t, home, "bad.json", "{not valid json")
+
+ stdout, _, err := runRootArgs(t,
+ "teach",
+ "--query", "graceful degrade query",
+ "--resource", "widget-deg",
+ "--resource-type", "items",
+ "--playbook-file", badPath,
+ "--db", dbPath,
+ )
+ if err != nil {
+ t.Fatalf("teach should not fail on bad playbook input; got %v", err)
+ }
+ if stdout != "" {
+ t.Errorf("teach should stay silent on degrade path; stdout=%q", stdout)
+ }
+
+ s, _ := store.OpenWithContext(context.Background(), dbPath)
+ defer s.Close()
+ rows, _ := listLearningsRows(context.Background(), s, store.ListLearningsFilter{})
+ if len(rows) != 1 {
+ t.Errorf("resource learning should still land despite playbook failure; got %d rows", len(rows))
+ }
+ pb, _ := s.ListPlaybooks()
+ if len(pb) != 0 {
+ t.Errorf("no playbook should land when input fails to parse; got %d", len(pb))
+ }
+
+ // teach.log should record the upsert failure for diagnosis.
+ stateDir, stateErr := cliutil.StateDir()
+ if stateErr != nil {
+ t.Fatalf("StateDir() error = %v", stateErr)
+ }
+ logPath := filepath.Join(stateDir, teachErrLogFileName)
+ data, statErr := os.ReadFile(logPath)
+ if statErr != nil {
+ t.Fatalf("teach.log should exist on degrade: %v", statErr)
+ }
+ if !strings.Contains(string(data), "playbook upsert") {
+ t.Errorf("teach.log should mention playbook upsert; got %q", string(data))
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/track.go b/library/productivity/human-goat/internal/cli/track.go
new file mode 100644
index 0000000000..f3bcfab291
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/track.go
@@ -0,0 +1,84 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+// pp:data-source live
+
+package cli
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/magic"
+)
+
+type magicTrackOutput struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Title string `json:"title,omitempty"`
+ CompletedAt string `json:"completed_at,omitempty"`
+ Answer string `json:"answer,omitempty"`
+ InProgress bool `json:"in_progress"`
+}
+
+func newMagicTrackCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "track ",
+ Short: "Track a Magic request",
+ Example: " human-goat-pp-cli track req_123",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && cmd.Flags().NFlag() == 0 {
+ return cmd.Help()
+ }
+ if len(args) != 1 {
+ return usageErr(fmt.Errorf("track requires exactly one request id"))
+ }
+ requestID := strings.TrimSpace(args[0])
+ if requestID == "" {
+ return usageErr(fmt.Errorf("request id is required"))
+ }
+
+ ctx, cancel := boundCtx(cmd.Context(), flags)
+ defer cancel()
+
+ client, err := magic.NewClient()
+ if err != nil {
+ return fmt.Errorf("initialize Magic client: %w", err)
+ }
+ req, err := client.GetRequest(ctx, requestID)
+ if err != nil {
+ return fmt.Errorf("track Magic request %s: %w", requestID, err)
+ }
+ out := magicTrackSummary(req)
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ if out.InProgress {
+ fmt.Fprintf(cmd.OutOrStdout(), "in progress (status=%s)\n", out.Status)
+ return nil
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "status: %s\n", out.Status)
+ if out.Answer != "" {
+ fmt.Fprintln(cmd.OutOrStdout(), out.Answer)
+ }
+ return nil
+ },
+ }
+ return cmd
+}
+
+func magicTrackSummary(req *magic.Request) magicTrackOutput {
+ if req == nil {
+ return magicTrackOutput{}
+ }
+ return magicTrackOutput{
+ ID: req.ID,
+ Status: req.Status,
+ Title: req.Title,
+ CompletedAt: req.CompletedAt,
+ Answer: req.Answer(),
+ InProgress: magic.IsInProgress(req.Status),
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/version.go b/library/productivity/human-goat/internal/cli/version.go
new file mode 100644
index 0000000000..1dd1286fae
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/version.go
@@ -0,0 +1,25 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+// version is the printed CLI's version, overridable at build time via ldflags.
+var version = "1.0.0"
+
+// newVersionCmd prints the CLI name and version. Shared by the HTTP and device
+// generators so both printed-CLI shapes carry an identical version command.
+func newVersionCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "version",
+ Short: "Print version",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Printf("%s %s\n", cmd.Root().Name(), version)
+ },
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/watch.go b/library/productivity/human-goat/internal/cli/watch.go
new file mode 100644
index 0000000000..adee0f2672
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/watch.go
@@ -0,0 +1,162 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// pp:data-source live
+// Novel command scaffold. Implement the RunE body before shipping.
+// generate --force preserves implemented bodies; untouched TODO scaffolds may refresh.
+
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/source/taskrabbit"
+
+ "github.com/spf13/cobra"
+)
+
+func newNovelWatchCmd(flags *rootFlags) *cobra.Command {
+ var flagOn string
+ var flagMinRating float64
+ var flagMaxRate float64
+ var flagLat float64
+ var flagLng float64
+ var flagState string
+ var flagLimit int
+ var flagInterval time.Duration
+ var flagMaxWait time.Duration
+
+ cmd := &cobra.Command{
+ Use: "watch ",
+ Short: "Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens.",
+ Example: "human-goat-pp-cli watch movers --on saturday --max-rate 60 --lat 37.7749 --lng -122.4194",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 && !commandHasChangedFlags(cmd) {
+ return cmd.Help()
+ }
+ query := strings.TrimSpace(strings.Join(args, " "))
+ if dryRunOK(flags) {
+ fmt.Fprintln(cmd.OutOrStdout(), "would poll for a qualifying opening")
+ return nil
+ }
+ if query == "" {
+ return usageErr(fmt.Errorf("missing job-query"))
+ }
+ if !cmd.Flags().Changed("lat") || !cmd.Flags().Changed("lng") {
+ return usageErr(fmt.Errorf("pass --lat and --lng for your location"))
+ }
+ if flagLimit < 0 {
+ return usageErr(fmt.Errorf("--limit must be non-negative"))
+ }
+ if flagInterval <= 0 {
+ return usageErr(fmt.Errorf("--interval must be positive"))
+ }
+ if flagMaxWait < 0 {
+ return usageErr(fmt.Errorf("--max-wait must be non-negative"))
+ }
+
+ date, err := parseOnDate(flagOn)
+ if err != nil {
+ return usageErr(err)
+ }
+
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ // Bind the poll context to the --max-wait deadline so a hung API call
+ // inside the loop is cancelled at the deadline, not just checked
+ // between polls.
+ deadline := time.Now().Add(flagMaxWait)
+ pollCtx := cmd.Context()
+ if flagMaxWait > 0 {
+ var cancel context.CancelFunc
+ pollCtx, cancel = context.WithDeadline(cmd.Context(), deadline)
+ defer cancel()
+ }
+
+ category, err := resolveTaskRabbitCategory(pollCtx, c, query)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ tr := taskrabbit.New(c)
+ opts := taskrabbitRankOptions{
+ Date: date,
+ MinRating: flagMinRating,
+ MaxRate: flagMaxRate,
+ Lat: flagLat,
+ Lng: flagLng,
+ State: flagState,
+ Limit: flagLimit,
+ }
+
+ pollLoop:
+ for {
+ rows, err := rankedTaskRabbitRecommendations(pollCtx, tr, category, opts)
+ if err != nil {
+ // Our --max-wait deadline expiring mid-call is a clean timeout,
+ // not a failure: fall through to the friendly message. A cancel
+ // on the parent context (user Ctrl-C) is still surfaced.
+ if pollDeadlineReached(cmd.Context(), err) {
+ break pollLoop
+ }
+ return classifyAPIError(err, flags)
+ }
+ if len(rows) > 0 {
+ return printTaskerRankRow(cmd, flags, rows[0])
+ }
+ if cliutil.IsDogfoodEnv() || !time.Now().Before(deadline) {
+ break
+ }
+
+ wait := flagInterval
+ if remaining := time.Until(deadline); remaining < wait {
+ wait = remaining
+ }
+ if wait <= 0 {
+ break
+ }
+ timer := time.NewTimer(wait)
+ select {
+ case <-pollCtx.Done():
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ if cmd.Context().Err() != nil {
+ return cmd.Context().Err()
+ }
+ // Our --max-wait deadline elapsed during the wait.
+ break pollLoop
+ case <-timer.C:
+ }
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "no qualifying opening within %s\n", flagMaxWait)
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&flagOn, "on", "", "Date to search: YYYY-MM-DD, today, tomorrow, or weekday")
+ cmd.Flags().Float64Var(&flagMinRating, "min-rating", 0, "Minimum Tasker rating")
+ cmd.Flags().Float64Var(&flagMaxRate, "max-rate", 0, "Maximum all-in hourly rate in dollars (0 for no ceiling)")
+ cmd.Flags().Float64Var(&flagLat, "lat", 0, "Latitude for TaskRabbit recommendations")
+ cmd.Flags().Float64Var(&flagLng, "lng", 0, "Longitude for TaskRabbit recommendations")
+ cmd.Flags().StringVar(&flagState, "state", "", "State for CA/MA service-fee-only pricing rule")
+ cmd.Flags().IntVar(&flagLimit, "limit", 10, "Maximum number of Taskers to consider")
+ cmd.Flags().DurationVar(&flagInterval, "interval", 60*time.Second, "Polling interval")
+ cmd.Flags().DurationVar(&flagMaxWait, "max-wait", 10*time.Minute, "Maximum time to wait for a qualifying opening")
+ return cmd
+}
+
+// pollDeadlineReached reports whether err is the watch loop's own --max-wait
+// deadline expiring (a clean timeout) rather than a real API error or a user
+// cancellation on the parent context.
+func pollDeadlineReached(parent context.Context, err error) bool {
+ return errors.Is(err, context.DeadlineExceeded) && parent.Err() == nil
+}
diff --git a/library/productivity/human-goat/internal/cli/watch_test.go b/library/productivity/human-goat/internal/cli/watch_test.go
new file mode 100644
index 0000000000..f6ca17ced4
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/watch_test.go
@@ -0,0 +1,32 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// cli-printing-press: novel-scaffold-test
+// Novel command scaffold tests. Keep the wiring smoke test and add behavior cases as needed.
+
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// TestNovelWatchHelpWires smoke-tests that the watch command
+// resolves at runtime and renders useful --help output. Catches wiring
+// regressions (missing AddCommand, panicking RunE on --help, etc.) before
+// review. Keep this smoke test when adding behavior-specific cases.
+func TestNovelWatchHelpWires(t *testing.T) {
+ cmd := RootCmd()
+ cmd.SetArgs([]string{"watch", "--help"})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("watch --help error = %v (novel command not wired correctly?)", err)
+ }
+ help := out.String()
+ for _, want := range []string{"Usage:", "watch"} {
+ if !strings.Contains(help, want) {
+ t.Fatalf("watch --help missing %q in output:\n%s", want, help)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/cli/which.go b/library/productivity/human-goat/internal/cli/which.go
new file mode 100644
index 0000000000..4dffaa40c8
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/which.go
@@ -0,0 +1,220 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// whichEntry is one row of the curated capability index. The index is seeded
+// at generation time from the verified NovelFeature list that drives the
+// SKILL.md feature section, so the command a `which` query returns is
+// guaranteed to exist and to match what the skill advertises.
+type whichEntry struct {
+ Command string `json:"command"`
+ Description string `json:"description"`
+ Group string `json:"group,omitempty"`
+ WhyItMatters string `json:"why_it_matters,omitempty"`
+}
+
+// whichIndex is the curated list of capabilities this CLI advertises as
+// its hero features. Endpoint-level commands are discoverable via
+// `--help`; `which` exists to resolve a natural-language capability
+// query to one of the commands the skill says matter most.
+var whichIndex = []whichEntry{
+ {Command: "hire", Description: "Say the job and the date; goat searches, ranks by review quality and honest all-in price, and checks out against the card on file with no prompt.", Group: "Hands-off hiring", WhyItMatters: "Reach for this when the user wants a task done, not a Tasker shortlist to review."},
+ {Command: "cancel", Description: "Cancels a booking and confirms it landed by re-reading status, reporting whether it was inside the free window and any fee.", Group: "Hands-off hiring", WhyItMatters: "This is the undo that makes autonomous checkout tolerable; always available after a hire."},
+ {Command: "hire", Description: "Refuses to check out when the computed all-in total exceeds a configurable ceiling, printing the total and the cap.", Group: "Hands-off hiring", WhyItMatters: "Use --max-total (or the config default) to cap autonomous spend before any booking is placed."},
+ {Command: "best", Description: "Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate, honoring the CA/MA service-fee-only rule.", Group: "Honest pricing", WhyItMatters: "Every price surface (search, compare, best, hire, spend) shows the real all-in rate, not the teaser."},
+ {Command: "dispatch", Description: "Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override.", Group: "One surface, two human networks", WhyItMatters: "Say what you want done and let goat pick the human network; force it with --via taskrabbit|magic."},
+ {Command: "spend", Description: "SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month, using true effective all-in $/hr for TaskRabbit.", Group: "One surface, two human networks", WhyItMatters: "Answers where the money went across both human networks with fees folded in."},
+ {Command: "watch", Description: "Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens.", Group: "Honest pricing", WhyItMatters: "Use when nothing good is available now and you want the first qualifying slot."},
+ {Command: "status", Description: "One list of every in-flight task across TaskRabbit bookings and Magic requests, joined on the common task model and sorted by state.", Group: "One surface, two human networks", WhyItMatters: "Use for a cross-source view of everything in flight; use 'tasks list' for TR-only and 'track ' for one Magic request."},
+}
+
+// whichMatch pairs an index entry with its ranking score for a query.
+// Higher score means stronger match. The ranker is naive (exact token
+// then substring then group tag) because 20-40 entries do not need
+// semantic retrieval - a ranker upgrade is a future change that would
+// not break this contract.
+type whichMatch struct {
+ Entry whichEntry `json:"entry"`
+ Score int `json:"score"`
+}
+
+// rankWhich returns up to `limit` best matches for `query` against the
+// index, sorted by descending score. Score breakdown:
+//
+// +3 exact token match on the command's leaf or full path
+// +2 substring match on the command (any part)
+// +2 substring match on the description
+// +1 group tag contains the query as a word
+//
+// Ties break on declaration order in the index. An empty query returns
+// every entry at score 0 in declaration order - this is the "list all"
+// behavior the skill documents for broad agent discovery.
+func rankWhich(index []whichEntry, query string, limit int) []whichMatch {
+ if limit <= 0 {
+ limit = 3
+ }
+ q := strings.ToLower(strings.TrimSpace(query))
+ if q == "" {
+ out := make([]whichMatch, 0, len(index))
+ for _, e := range index {
+ out = append(out, whichMatch{Entry: e, Score: 0})
+ }
+ return out
+ }
+ qTokens := strings.Fields(q)
+
+ scored := make([]whichMatch, 0, len(index))
+ for i, e := range index {
+ score := whichScoreEntry(e, q, qTokens)
+ scored = append(scored, whichMatch{Entry: e, Score: score})
+ _ = i
+ }
+
+ sort.SliceStable(scored, func(i, j int) bool {
+ return scored[i].Score > scored[j].Score
+ })
+ // Drop zero-score matches when the query was non-empty; agents
+ // branching on exit code rely on "no match" meaning no confidence.
+ filtered := scored[:0]
+ for _, m := range scored {
+ if m.Score > 0 {
+ filtered = append(filtered, m)
+ }
+ }
+ if len(filtered) > limit {
+ filtered = filtered[:limit]
+ }
+ return filtered
+}
+
+func whichScoreEntry(e whichEntry, query string, qTokens []string) int {
+ score := 0
+ cmd := strings.ToLower(e.Command)
+ cmdTokens := strings.Fields(cmd)
+ desc := strings.ToLower(e.Description)
+ group := strings.ToLower(e.Group)
+
+ // Exact token match on the command path (any token).
+ for _, qt := range qTokens {
+ for _, ct := range cmdTokens {
+ if qt == ct {
+ score += 3
+ break
+ }
+ }
+ }
+ // Substring match on the full command (covers hyphenated leaves).
+ if strings.Contains(cmd, query) {
+ score += 2
+ }
+ // Substring match on the description.
+ if strings.Contains(desc, query) {
+ score += 2
+ }
+ // Group tag match.
+ if group != "" {
+ for _, qt := range qTokens {
+ if strings.Contains(group, qt) {
+ score += 1
+ break
+ }
+ }
+ }
+ return score
+}
+
+func newWhichCmd(flags *rootFlags) *cobra.Command {
+ var limit int
+ cmd := &cobra.Command{
+ Use: "which [query]",
+ Short: "Find the command that implements a capability",
+ Annotations: map[string]string{
+ "mcp:read-only": "true",
+ "pp:typed-exit-codes": "0,2",
+ },
+ Long: `which resolves a natural-language capability query (for example, "search messages" or "stale tickets") to the best matching command from this CLI's curated feature index.
+
+Exit codes:
+ 0 at least one match found
+ 2 no confident match - the query did not score against any indexed capability; fall back to '--help' or 'search' if this CLI has one`,
+ Example: ` human-goat-pp-cli which "stale tickets"
+ human-goat-pp-cli which "bottleneck"
+ human-goat-pp-cli which --limit 1 "send message"
+ human-goat-pp-cli which # list the full capability index`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(whichIndex) == 0 {
+ return usageErr(fmt.Errorf("this CLI has no curated capability index; run '--help' to see every command"))
+ }
+ query := strings.Join(args, " ")
+ matches := rankWhich(whichIndex, query, limit)
+
+ // Empty query returns the whole index at score 0 (listing mode).
+ if strings.TrimSpace(query) == "" {
+ return renderWhich(cmd, flags, rankWhichAll(whichIndex))
+ }
+
+ if len(matches) == 0 {
+ // Under --json, return an empty matches envelope at exit 0
+ // so agents can branch on `matches.length == 0` instead of
+ // parsing a usage error message. Non-JSON keeps the typed
+ // exit-2 path so terminal users see the help hint.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "matches": []whichMatch{},
+ }, flags)
+ }
+ return usageErr(fmt.Errorf("no match for %q; try '%s --help' for the full command list", query, cmd.Root().Name()))
+ }
+ return renderWhich(cmd, flags, matches)
+ },
+ }
+ cmd.Flags().IntVar(&limit, "limit", 3, "Maximum number of matches to return")
+ return cmd
+}
+
+// rankWhichAll is a narrow helper used by the "empty query lists the
+// index" path. It returns every entry in declaration order at score 0
+// so the render path treats them uniformly.
+func rankWhichAll(index []whichEntry) []whichMatch {
+ out := make([]whichMatch, 0, len(index))
+ for _, e := range index {
+ out = append(out, whichMatch{Entry: e, Score: 0})
+ }
+ return out
+}
+
+func renderWhich(cmd *cobra.Command, flags *rootFlags, matches []whichMatch) error {
+ w := cmd.OutOrStdout()
+ // Output shape follows the same rule as every other generated
+ // command: JSON when the caller asked for it OR when stdout is not
+ // a terminal; table when a human is looking.
+ asJSON := flags.asJSON
+ if !asJSON && !isTerminal(w) {
+ asJSON = true
+ }
+ if asJSON {
+ // JSON envelope: {matches: [...]}. The wrap is critical:
+ // printJSONFiltered's --compact path uses compactListFields
+ // (allowlist) for top-level arrays, which would strip
+ // entry/score keys; routing through compactObjectFields
+ // (blocklist) via an object envelope preserves them.
+ if matches == nil {
+ matches = []whichMatch{}
+ }
+ return printJSONFiltered(w, map[string]any{"matches": matches}, flags)
+ }
+ fmt.Fprintf(w, "%-24s %-8s %s\n", "COMMAND", "SCORE", "DESCRIPTION")
+ for _, m := range matches {
+ fmt.Fprintf(w, "%-24s %-8d %s\n", m.Entry.Command, m.Score, m.Entry.Description)
+ }
+ return nil
+}
diff --git a/library/productivity/human-goat/internal/cli/which_test.go b/library/productivity/human-goat/internal/cli/which_test.go
new file mode 100644
index 0000000000..76f7c043ba
--- /dev/null
+++ b/library/productivity/human-goat/internal/cli/which_test.go
@@ -0,0 +1,104 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "strings"
+ "testing"
+)
+
+// Fixture index used across which-ranking tests. Covers a typical mix
+// of single-word commands, multi-word commands, and grouped entries so
+// the ranker is exercised against shapes a generated CLI actually
+// produces.
+var whichTestIndex = []whichEntry{
+ {Command: "search", Description: "Full-text search across synced resources", Group: "Local state"},
+ {Command: "stale", Description: "Find tickets that have not moved in a while", Group: "Local state"},
+ {Command: "bottleneck", Description: "Identify pipeline bottlenecks", Group: "Local state"},
+ {Command: "send", Description: "Send a message", Group: "Write operations"},
+ {Command: "sync", Description: "Sync resources to local SQLite", Group: "Local state"},
+}
+
+// Happy path: a query that matches a command by keyword returns that
+// command first. This is the load-bearing promise of `which`.
+func TestRankWhich_ExactTokenMatchWins(t *testing.T) {
+ got := rankWhich(whichTestIndex, "search", 3)
+ if len(got) == 0 {
+ t.Fatalf("expected at least one match, got zero")
+ }
+ if got[0].Entry.Command != "search" {
+ t.Errorf("top match: want search, got %s", got[0].Entry.Command)
+ }
+}
+
+// Happy path: a query matching the description wins when the command
+// itself does not contain the query tokens.
+func TestRankWhich_DescriptionMatch(t *testing.T) {
+ got := rankWhich(whichTestIndex, "bottlenecks", 3)
+ if len(got) == 0 || got[0].Entry.Command != "bottleneck" {
+ t.Errorf("expected bottleneck command as top match for bottlenecks query, got %+v", got)
+ }
+}
+
+// Happy path: a multi-word query resolves to the best single match by
+// summing per-token scores.
+func TestRankWhich_MultiTokenQuery(t *testing.T) {
+ got := rankWhich(whichTestIndex, "send a message", 3)
+ if len(got) == 0 || got[0].Entry.Command != "send" {
+ t.Errorf("expected send as top match for 'send a message', got %+v", got)
+ }
+}
+
+// Edge case: empty query should surface the full index (listing mode)
+// rather than treating as no-match. Agents use this for broad discovery.
+func TestRankWhich_EmptyQueryListsIndex(t *testing.T) {
+ got := rankWhich(whichTestIndex, "", 3)
+ if len(got) != len(whichTestIndex) {
+ t.Errorf("empty query should return all %d entries, got %d", len(whichTestIndex), len(got))
+ }
+ for i, m := range got {
+ if m.Score != 0 {
+ t.Errorf("empty query entry %d: score should be 0, got %d", i, m.Score)
+ }
+ }
+}
+
+// Edge case: the limit flag caps the result set so agents can ask for
+// a single top answer when they want a deterministic branch.
+func TestRankWhich_LimitCapsResults(t *testing.T) {
+ got := rankWhich(whichTestIndex, "local", 1)
+ if len(got) > 1 {
+ t.Errorf("limit=1 should return at most 1 match, got %d", len(got))
+ }
+}
+
+// No-match path: a query that hits nothing in the index returns an
+// empty slice so the caller can exit with the no-match code (2) rather
+// than printing a misleading best-effort result.
+func TestRankWhich_NoMatchReturnsEmpty(t *testing.T) {
+ got := rankWhich(whichTestIndex, "nonexistentxyz", 3)
+ if len(got) != 0 {
+ t.Errorf("nonsense query should return zero matches, got %d (%+v)", len(got), got)
+ }
+}
+
+// Sanity: whichIndex compiles and is well-formed. Generated CLIs with
+// zero NovelFeatures ship an empty index, and that is still a valid
+// state (which returns the "no curated index" error at runtime).
+func TestWhichIndex_ExistsAndIsWellFormed(t *testing.T) {
+ root := newRootCmd(&rootFlags{})
+ for i, e := range whichIndex {
+ if e.Command == "" {
+ t.Errorf("whichIndex[%d] has empty Command - template rendered bad data", i)
+ continue
+ }
+ if strings.TrimSpace(e.Description) == "" {
+ t.Errorf("whichIndex[%d] (%s) has empty Description - template rendered bad data", i, e.Command)
+ }
+ found, remaining, err := root.Find(strings.Fields(e.Command))
+ if err != nil || len(remaining) > 0 {
+ t.Errorf("whichIndex[%d] command %q does not resolve in the Cobra tree (found=%v, remaining=%v, err=%v)", i, e.Command, found, remaining, err)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/client/client.go b/library/productivity/human-goat/internal/client/client.go
new file mode 100644
index 0000000000..d76123930c
--- /dev/null
+++ b/library/productivity/human-goat/internal/client/client.go
@@ -0,0 +1,993 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/config"
+ "io"
+ "math"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+const BinaryResponseHeader = "X-Printing-Press-Binary-Response"
+
+var ErrPlaceholderCredential = errors.New("auth placeholder credential")
+
+type Client struct {
+ BaseURL string
+ Config *config.Config
+ HTTPClient *http.Client
+ DryRun bool
+ NoCache bool
+ cacheDir string
+ limiter *cliutil.AdaptiveLimiter
+}
+
+// RequestBaseURL returns the base URL used for requests.
+// Novel commands that build request URLs by hand should use this instead of
+// concatenating c.BaseURL directly.
+func (c *Client) RequestBaseURL() string {
+ return c.BaseURL
+}
+
+// APIError carries HTTP status information for structured exit codes.
+type APIError struct {
+ Method string
+ Path string
+ StatusCode int
+ Body string
+}
+
+func (e *APIError) Error() string {
+ return fmt.Sprintf("%s %s returned HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
+}
+
+func rejectUnresolvedPathParams(path string, allowedTemplateVars map[string]string) error {
+ for {
+ start := strings.IndexByte(path, '{')
+ if start < 0 {
+ return nil
+ }
+ rest := path[start+1:]
+ end := strings.IndexByte(rest, '}')
+ if end < 0 {
+ return nil
+ }
+ name := rest[:end]
+ if _, ok := allowedTemplateVars[name]; ok {
+ path = rest[end+1:]
+ continue
+ }
+ if isPathPlaceholderName(name) {
+ return fmt.Errorf("unresolved path parameter {%s}", name)
+ }
+ path = rest[end+1:]
+ }
+}
+
+func isPathPlaceholderName(name string) bool {
+ if name == "" {
+ return false
+ }
+ for i, r := range name {
+ if r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9' {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+func newHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {
+ return &http.Client{Timeout: timeout, Jar: jar}
+}
+
+func New(cfg *config.Config, timeout time.Duration, rateLimit float64) *Client {
+ cacheDir := ""
+ if dir, err := cliutil.CacheDir(); err == nil {
+ cacheDir = filepath.Join(dir, "http")
+ }
+ cookieJar := LoadCookieJar()
+ // Seed the jar from a session captured via the env var or stored in
+ // credentials but not yet in cookies.json, so the credential rides every
+ // request and net/http absorbs Set-Cookie rotation across the session.
+ if cfg != nil {
+ SeedCookieJar(cookieJar, cfg.BaseURL, cfg.CookieCredential())
+ }
+ httpClient := newHTTPClient(timeout, cookieJar)
+ c := &Client{
+ BaseURL: strings.TrimRight(cfg.BaseURL, "/"),
+ Config: cfg,
+ HTTPClient: httpClient,
+ cacheDir: cacheDir,
+ limiter: cliutil.NewAdaptiveLimiter(rateLimit),
+ }
+ // CheckRedirect re-derives auth on each hop. Go's default replays the
+ // original Authorization header verbatim, which breaks nonce-bound
+ // schemes (OAuth 1.0a PLAINTEXT, SigV4, Hawk): the duplicate nonce
+ // trips the server's replay detector with a 401. c.authHeader()
+ // returns a fresh value for those schemes and the same static value
+ // for Bearer/api_key, so post-redirect headers are byte-identical for
+ // static auth and freshly-signed for nonce-bound auth.
+ httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ // Match Go's defaultCheckRedirect: a plain error so Client.Do
+ // returns it through do()'s err != nil branch. ErrUseLastResponse
+ // would cause Do to return the 3xx with nil error, which do()
+ // would then classify as a successful response and hand the HTML
+ // "Moved Permanently" body back to the caller.
+ return errors.New("stopped after 10 redirects")
+ }
+ // Cookie-auth redirects: Go's http.Client + http.CookieJar handle
+ // cookie carriage on 3xx hops natively. Setting the Cookie header
+ // manually here would double the jar's cookies on the redirected
+ // request.
+ return nil
+ }
+ return c
+}
+
+// RateLimit returns the current effective rate limit in req/s. Returns 0 if disabled.
+func (c *Client) RateLimit() float64 {
+ return c.limiter.Rate()
+}
+
+func (c *Client) Get(ctx context.Context, path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeaders(ctx, path, params, nil)
+}
+
+func (c *Client) GetWithHeaders(ctx context.Context, path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+ if err := c.validateCachedRequestAuth(ctx); err != nil {
+ return nil, err
+ }
+ binaryResponse := c.wantsBinaryResponse(headers)
+ cacheEnabled := c.responseCacheEnabled(binaryResponse)
+ // Check cache for GET requests
+ if cacheEnabled {
+ if cached, ok := c.readCache(path, params); ok {
+ return cached, nil
+ }
+ }
+ result, _, err := c.do(ctx, "GET", path, params, nil, headers)
+ if err == nil && cacheEnabled {
+ c.writeCache(path, params, result)
+ }
+ return result, err
+}
+
+func (c *Client) GetWithHeadersValues(ctx context.Context, path string, params url.Values, headers map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeaders(ctx, pathWithQueryValues(path, params), nil, headers)
+}
+
+// GetNoCache issues a GET that bypasses the cache read for this call only,
+// then refreshes the cache with the fresh response on success. Use for
+// polling-until-terminal patterns where every call must reflect current
+// server state; the same (path, params) pair returning a stale
+// "in-progress" snapshot from cache would lock the poll loop on the
+// initial response. Writing-back on success means subsequent c.Get calls
+// (e.g. a follow-up `... get ` after WaitForJob returns) see the
+// terminal value, not the stale non-terminal snapshot left behind by the
+// first poll.
+func (c *Client) GetNoCache(ctx context.Context, path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeadersNoCache(ctx, path, params, nil)
+}
+
+// GetWithHeadersNoCache is GetWithHeaders that skips the cache read but still
+// writes cacheable fresh responses on success. See GetNoCache for when to
+// prefer this over Get/GetWithHeaders.
+func (c *Client) GetWithHeadersNoCache(ctx context.Context, path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
+ binaryResponse := c.wantsBinaryResponse(headers)
+ result, _, err := c.do(ctx, "GET", path, params, nil, headers)
+ if err == nil && c.responseCacheEnabled(binaryResponse) {
+ c.writeCache(path, params, result)
+ }
+ return result, err
+}
+
+func (c *Client) GetWithHeadersNoCacheValues(ctx context.Context, path string, params url.Values, headers map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeadersNoCache(ctx, pathWithQueryValues(path, params), nil, headers)
+}
+
+func (c *Client) responseCacheEnabled(binaryResponse bool) bool {
+ return !binaryResponse && !c.NoCache && !c.DryRun && c.cacheDir != ""
+}
+
+// The response cache stores text/JSON bodies as .json. Binary callers
+// opt out so opaque blobs do not land under a misleading extension.
+func (c *Client) wantsBinaryResponse(headers map[string]string) bool {
+ binaryResponse := false
+ if c != nil && c.Config != nil {
+ if value, ok := binaryResponseHeaderValue(c.Config.Headers); ok {
+ binaryResponse = value
+ }
+ }
+ if value, ok := binaryResponseHeaderValue(headers); ok {
+ binaryResponse = value
+ }
+ return binaryResponse
+}
+
+func binaryResponseHeaderValue(headers map[string]string) (bool, bool) {
+ found := false
+ for k, v := range headers {
+ if strings.EqualFold(k, BinaryResponseHeader) {
+ found = true
+ if strings.EqualFold(v, "true") {
+ return true, true
+ }
+ }
+ }
+ return false, found
+}
+
+func (c *Client) validateCachedRequestAuth(ctx context.Context) error {
+ if c == nil || c.Config == nil {
+ return nil
+ }
+ if authHeaderLooksLikePlaceholderCredential(c.Config.AuthHeader()) {
+ return authPlaceholderCredentialError(c.Config)
+ }
+ return nil
+}
+
+func (c *Client) ProbeGet(ctx context.Context, path string) (int, error) {
+ _, status, err := c.do(ctx, "GET", path, nil, nil, nil)
+ return status, err
+}
+
+func (c *Client) cacheKey(path string, params map[string]string) string {
+ key := path
+ key += "|base_url=" + c.BaseURL
+ if c.Config != nil {
+ key += "|auth_source=" + c.Config.AuthSource
+ if authHeader := c.Config.AuthHeader(); authHeader != "" {
+ authHash := sha256.Sum256([]byte(authHeader))
+ key += "|auth=" + hex.EncodeToString(authHash[:8])
+ }
+ if c.Config.Path != "" {
+ key += "|config_path=" + c.Config.Path
+ }
+ }
+ if len(params) > 0 {
+ query := url.Values{}
+ for k, v := range params {
+ query.Set(k, v)
+ }
+ key += "|query=" + query.Encode()
+ }
+ h := sha256.Sum256([]byte(key))
+ return hex.EncodeToString(h[:8])
+}
+
+func pathWithQueryValues(path string, params url.Values) string {
+ if len(params) == 0 {
+ return path
+ }
+ encoded := params.Encode()
+ if encoded == "" {
+ return path
+ }
+ separator := "?"
+ if strings.Contains(path, "?") {
+ separator = "&"
+ }
+ return path + separator + encoded
+}
+
+func (c *Client) readCache(path string, params map[string]string) (json.RawMessage, bool) {
+ cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
+ info, err := os.Stat(cacheFile)
+ if err != nil || time.Since(info.ModTime()) > 5*time.Minute {
+ return nil, false
+ }
+ data, err := os.ReadFile(cacheFile)
+ if err != nil {
+ return nil, false
+ }
+ return json.RawMessage(data), true
+}
+
+func (c *Client) writeCache(path string, params map[string]string, data json.RawMessage) {
+ os.MkdirAll(c.cacheDir, 0o700)
+ cacheFile := filepath.Join(c.cacheDir, c.cacheKey(path, params)+".json")
+ os.WriteFile(cacheFile, []byte(data), 0o600)
+}
+
+// invalidateCache wholesale-removes the cache directory so the next read
+// after a mutation cannot return a stale snapshot. Selective per-resource
+// invalidation rejected: cache keys are opaque sha256 hashes.
+func (c *Client) invalidateCache() {
+ if c.cacheDir == "" {
+ return
+ }
+ _ = os.RemoveAll(c.cacheDir)
+}
+
+func (c *Client) Post(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
+ return c.do(ctx, "POST", path, nil, body, nil)
+}
+
+func (c *Client) PostWithParams(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do(ctx, "POST", path, params, body, nil)
+}
+
+func (c *Client) PostWithHeaders(ctx context.Context, path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "POST", path, nil, body, headers)
+}
+
+func (c *Client) PostWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "POST", path, params, body, headers)
+}
+
+// PostQueryWithParams is a POST that does not mutate remote state — used
+// by read-only operations that ride a mutating verb on the wire (GraphQL
+// queries, JSON-RPC reads, POST-based search endpoints). The verify-mode
+// short-circuit does not fire for these calls; the request reaches the
+// real transport even under PRINTING_PRESS_VERIFY=1 without LIVE_HTTP=1.
+func (c *Client) PostQueryWithParams(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.doRead(ctx, "POST", path, params, body, nil)
+}
+
+// PostQueryWithParamsAndHeaders is the headers-aware counterpart to
+// PostQueryWithParams. See PostQueryWithParams for the verify-mode rationale.
+func (c *Client) PostQueryWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.doRead(ctx, "POST", path, params, body, headers)
+}
+
+func (c *Client) Delete(ctx context.Context, path string) (json.RawMessage, int, error) {
+ return c.do(ctx, "DELETE", path, nil, nil, nil)
+}
+
+func (c *Client) DeleteWithParams(ctx context.Context, path string, params map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "DELETE", path, params, nil, nil)
+}
+
+func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
+ return c.do(ctx, "DELETE", path, nil, body, nil)
+}
+
+func (c *Client) DeleteWithParamsAndBody(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do(ctx, "DELETE", path, params, body, nil)
+}
+
+func (c *Client) DeleteWithHeaders(ctx context.Context, path string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "DELETE", path, nil, nil, headers)
+}
+
+func (c *Client) DeleteWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "DELETE", path, params, nil, headers)
+}
+
+func (c *Client) DeleteWithBodyAndHeaders(ctx context.Context, path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "DELETE", path, nil, body, headers)
+}
+
+func (c *Client) DeleteWithParamsAndBodyAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "DELETE", path, params, body, headers)
+}
+
+func (c *Client) Put(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
+ return c.do(ctx, "PUT", path, nil, body, nil)
+}
+
+func (c *Client) PutWithParams(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do(ctx, "PUT", path, params, body, nil)
+}
+
+func (c *Client) PutWithHeaders(ctx context.Context, path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "PUT", path, nil, body, headers)
+}
+
+func (c *Client) PutWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "PUT", path, params, body, headers)
+}
+
+func (c *Client) Patch(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
+ return c.do(ctx, "PATCH", path, nil, body, nil)
+}
+
+func (c *Client) PatchWithParams(ctx context.Context, path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do(ctx, "PATCH", path, params, body, nil)
+}
+
+func (c *Client) PatchWithHeaders(ctx context.Context, path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "PATCH", path, nil, body, headers)
+}
+
+func (c *Client) PatchWithParamsAndHeaders(ctx context.Context, path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do(ctx, "PATCH", path, params, body, headers)
+}
+
+// isMutatingVerb reports whether the HTTP method writes server state.
+// Used by do()'s verify-mode short-circuit to gate dial-out: under
+// PRINTING_PRESS_VERIFY=1 (without LIVE_HTTP=1 opt-in), generated
+// commands must not actually issue mutating requests, even if a
+// handler-level dry-run check was missed.
+func isMutatingVerb(method string) bool {
+ switch method {
+ case "DELETE", "POST", "PUT", "PATCH":
+ return true
+ }
+ return false
+}
+
+// verifyShortCircuitEnvelope returns the synthetic JSON body that
+// stands in for a real mutating response when do() short-circuits in
+// verify mode. The __pp_verify_synthetic__ sentinel is namespace-
+// reserved (no real API uses __pp_*) so downstream consumers
+// (validate-narrative, agent inspections) can key on one obvious field
+// instead of trying to disambiguate common literals like status:"noop".
+// method and path are echoed back as diagnostic prose for human/agent
+// inspection.
+func verifyShortCircuitEnvelope(method, path string) json.RawMessage {
+ body, _ := json.Marshal(map[string]any{
+ "__pp_verify_synthetic__": true,
+ "status": "noop",
+ "reason": "verify_short_circuit",
+ "method": method,
+ "path": path,
+ })
+ return json.RawMessage(body)
+}
+
+func sleepContext(ctx context.Context, wait time.Duration) error {
+ timer := time.NewTimer(wait)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+// do executes an HTTP request. headerOverrides, when non-nil, override global
+// RequiredHeaders for this specific request (used for per-endpoint API versioning).
+func (c *Client) do(ctx context.Context, method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
+ return c.doInternal(ctx, method, path, params, body, headerOverrides, false)
+}
+
+// doRead is do() minus the verify-mode mutating-verb gate. Used by the
+// PostQuery* family for read-only operations that ride a mutating verb on
+// the wire (GraphQL queries, JSON-RPC reads, POST-based search endpoints).
+// The wire verb is still POST/PUT/PATCH so the server sees a real request,
+// but the verify-mode short-circuit does not fire because the operation
+// does not mutate remote state.
+func (c *Client) doRead(ctx context.Context, method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
+ return c.doInternal(ctx, method, path, params, body, headerOverrides, true)
+}
+
+// doInternal is the shared implementation behind do() and doRead(). The
+// readOnlyIntent flag is set by doRead() callers (read-only POST/PUT/PATCH
+// operations like GraphQL queries) to skip the mutating-verb verify-mode
+// gate. Plain do() callers leave it false and get the usual short-circuit.
+func (c *Client) doInternal(ctx context.Context, method, path string, params map[string]string, body any, headerOverrides map[string]string, readOnlyIntent bool) (json.RawMessage, int, error) {
+ // Verify-mode transport-layer gate. When the verifier (or any consumer
+ // that sets PRINTING_PRESS_VERIFY=1) drives a mutating verb without
+ // the LIVE_HTTP=1 opt-in, return a synthetic envelope without dialing,
+ // minting auth, or touching the cache. The verify pipeline itself
+ // sets both env vars in mock mode so its httptest server still sees
+ // real requests; every other consumer gets a safe no-op.
+ //
+ // readOnlyIntent suppresses the gate for read-only operations that
+ // happen to ride a mutating verb on the wire (GraphQL queries, JSON-RPC
+ // reads, POST-based search endpoints). The handler-level annotation
+ // `mcp:read-only: true` drives the codegen choice of doRead() vs do().
+ //
+ // Placement note: this fires BEFORE URL building, auth header
+ // minting, and the success-branch invalidateCache() call below — so
+ // no cache invalidation runs (no remote state changed) and no
+ // client_credentials mint happens unnecessarily.
+ if !readOnlyIntent && isMutatingVerb(method) && cliutil.IsVerifyEnv() && !cliutil.IsVerifyLiveHTTPEnv() {
+ return verifyShortCircuitEnvelope(method, path), http.StatusOK, nil
+ }
+ if err := rejectUnresolvedPathParams(path, nil); err != nil {
+ return nil, 0, err
+ }
+ targetURL := c.BaseURL + path
+
+ var bodyBytes []byte
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return nil, 0, fmt.Errorf("marshaling body: %w", err)
+ }
+ bodyBytes = b
+ }
+
+ // Resolve auth material before the dry-run branch so --dry-run can preview
+ // exactly what would be sent. Uses only cached credentials; a token that
+ // requires a network refresh will be re-fetched on the live request path,
+ // not during dry-run.
+ authHeader, err := c.authHeader(ctx)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ // Build the request for dry-run display or actual execution
+ if c.DryRun {
+ return c.dryRun(method, targetURL, path, params, bodyBytes, headerOverrides, authHeader)
+ }
+
+ maxRetries := clientMaxRetries()
+ var lastErr error
+
+ for attempt := 0; attempt <= maxRetries; attempt++ {
+ // Proactive rate limiting — wait before sending
+ c.limiter.Wait()
+ var bodyReader io.Reader
+ if bodyBytes != nil {
+ bodyReader = strings.NewReader(string(bodyBytes))
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
+ if err != nil {
+ return nil, 0, fmt.Errorf("creating request: %w", err)
+ }
+ if bodyBytes != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ if params != nil {
+ q := req.URL.Query()
+ for k, v := range params {
+ if v != "" {
+ q.Set(k, v)
+ }
+ }
+ req.URL.RawQuery = q.Encode()
+ }
+
+ if authHeader != "" {
+ // Cookie-auth: the cookie jar is the sole source of outbound
+ // cookies. New() loads it from cookies.json and seeds it from
+ // the env-var / credentials session via SeedCookieJar, so every
+ // cookie source flows through the jar. net/http's Client.send
+ // calls jar.Cookies + AddCookie for each cookie, which
+ // concatenates without dedup; a manual req.Header.Set here would
+ // ship every session cookie twice and trip upstream WAFs.
+ // authHeader is read only by the dry-run / signing paths above;
+ // intentionally not consumed on the live wire here.
+ }
+ if c.Config != nil {
+ for k, v := range c.Config.Headers {
+ req.Header.Set(k, v)
+ }
+ }
+ // Per-endpoint header overrides (e.g., different API version per resource)
+ for k, v := range headerOverrides {
+ req.Header.Set(k, v)
+ }
+ binaryResponse := strings.EqualFold(req.Header.Get(BinaryResponseHeader), "true")
+ if binaryResponse {
+ req.Header.Del(BinaryResponseHeader)
+ }
+ if req.Header.Get("User-Agent") == "" {
+ // Browser-shaped UA: synthetic specs and cookie/composed/
+ // session_handshake auth almost always target website-itself
+ // origins whose WAFs (Wordfence, Imperva, Akamai bot-mode,
+ // DataDome, Cloudflare bot-fight) bucket the script-shaped
+ // `-pp-cli/` string as a bot and answer with
+ // empty 5xx or a challenge. RequiredHeaders or per-endpoint
+ // headerOverrides still win, and any caller that wants the
+ // CLI-flavored UA can set it on the request explicitly.
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36")
+ }
+ // Go's net/http omits Accept by default; browsers, curl, and other
+ // stdlibs always send it. Fingerprint-checking WAFs (Imperva, Akamai,
+ // Cloudflare bot-mode, DataDome) flag the absence as a bot signal
+ // and answer with empty-body 5xx, 403, or a challenge redirect
+ // depending on vendor and rule tier. The value is application/json
+ // rather than */* because strict-JSON APIs (Zendesk, Atlassian REST,
+ // Salesforce) return 415 on anything that isn't literally
+ // application/json; specs that need a different content type
+ // (vendor mediatypes, XML, HTML) declare it via RequiredHeaders or
+ // per-endpoint headerOverrides, both of which run before this
+ // if-empty default.
+ if req.Header.Get("Accept") == "" {
+ if binaryResponse {
+ req.Header.Set("Accept", "*/*")
+ } else {
+ req.Header.Set("Accept", "application/json")
+ }
+ }
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return nil, 0, ctxErr
+ }
+ lastErr = fmt.Errorf("%s %s: %w", method, c.displayURL(path, authHeader), c.maskError(err, authHeader))
+ continue
+ }
+
+ respBody, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return nil, 0, fmt.Errorf("reading response: %w", err)
+ }
+
+ // Success
+ if resp.StatusCode < 400 {
+ c.limiter.OnSuccess()
+ if method != http.MethodGet && !c.DryRun {
+ c.invalidateCache()
+ }
+ // Non-textual bodies (PDF, zip, image, octet-stream) must not be
+ // run through the JSON sanitizer or returned as raw json.RawMessage
+ // — return a self-describing base64 envelope instead. Textual and
+ // JSON responses fall through to the unchanged path.
+ if isBinaryResponseContentType(resp.Header.Get("Content-Type")) {
+ env, encErr := wrapBinaryResponse(resp.Header.Get("Content-Type"), respBody)
+ if encErr != nil {
+ return nil, 0, encErr
+ }
+ return env, resp.StatusCode, nil
+ }
+ return json.RawMessage(sanitizeJSONResponse(respBody)), resp.StatusCode, nil
+ }
+
+ if !binaryResponse {
+ respBody = sanitizeJSONResponse(respBody)
+ }
+
+ apiErr := &APIError{
+ Method: method,
+ Path: c.displayURL(path, authHeader),
+ StatusCode: resp.StatusCode,
+ Body: c.maskCredentialText(truncateBody(respBody), authHeader),
+ }
+
+ // Rate limited - adjust adaptive limiter and retry
+ if resp.StatusCode == 429 && attempt < maxRetries {
+ c.limiter.OnRateLimit()
+ wait := cliutil.RetryAfter(resp)
+ fmt.Fprintf(os.Stderr, "rate limited, waiting %s (attempt %d/%d, rate adjusted to %.1f req/s)\n", wait, attempt+1, maxRetries, c.limiter.Rate())
+ if err := sleepContext(ctx, wait); err != nil {
+ return nil, 0, err
+ }
+ lastErr = apiErr
+ continue
+ }
+
+ // Server error - retry with backoff
+ if resp.StatusCode >= 500 && attempt < maxRetries {
+ wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
+ fmt.Fprintf(os.Stderr, "server error %d, retrying in %s (attempt %d/%d)\n", resp.StatusCode, wait, attempt+1, maxRetries)
+ if err := sleepContext(ctx, wait); err != nil {
+ return nil, 0, err
+ }
+ lastErr = apiErr
+ continue
+ }
+
+ // Client error or retries exhausted - return the error
+ return nil, resp.StatusCode, apiErr
+ }
+
+ return nil, 0, lastErr
+}
+
+// dryRun prints the outgoing request exactly as the live path would send it,
+// using the auth material already resolved in `do()`. Never triggers a network
+// call — the caller is responsible for passing cached auth material only.
+func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string, authHeader string) (json.RawMessage, int, error) {
+ fmt.Fprintf(os.Stderr, "%s %s\n", method, c.displayURL(targetURL, authHeader))
+ queryPrinted := false
+ if params != nil {
+ keys := make([]string, 0, len(params))
+ for k := range params {
+ if params[k] != "" {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ sep := "?"
+ if queryPrinted {
+ sep = "&"
+ }
+ fmt.Fprintf(os.Stderr, " %s%s=%s\n", sep, k, c.maskCredentialText(params[k], authHeader))
+ queryPrinted = true
+ }
+ }
+ _ = queryPrinted
+ if body != nil {
+ var pretty json.RawMessage
+ if json.Unmarshal(body, &pretty) == nil {
+ enc := json.NewEncoder(os.Stderr)
+ enc.SetIndent(" ", " ")
+ fmt.Fprintf(os.Stderr, " Body:\n")
+ enc.Encode(pretty)
+ }
+ }
+ if authHeader != "" {
+ fmt.Fprintf(os.Stderr, " %s: %s\n", "Cookie", maskToken(authHeader))
+ }
+ fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
+ return json.RawMessage(`{"dry_run": true}`), 0, nil
+}
+
+func (c *Client) ConfiguredTimeout() time.Duration {
+ if c.HTTPClient != nil && c.HTTPClient.Timeout > 0 {
+ return c.HTTPClient.Timeout
+ }
+ return 60 * time.Second
+}
+
+func (c *Client) authHeader(ctx context.Context) (string, error) {
+ if c.Config == nil {
+ return "", nil
+ }
+ authHeader := c.Config.AuthHeader()
+ if authHeaderLooksLikePlaceholderCredential(authHeader) {
+ return "", authPlaceholderCredentialError(c.Config)
+ }
+ return authHeader, nil
+}
+
+func authHeaderLooksLikePlaceholderCredential(header string) bool {
+ if scheme, encoded, ok := strings.Cut(strings.TrimSpace(header), " "); ok && strings.EqualFold(scheme, "Basic") {
+ encoded = strings.TrimSpace(encoded)
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err == nil && authHeaderLooksLikePlaceholderCredential(string(decoded)) {
+ return true
+ }
+ }
+ if !strings.Contains(header, "<") && !strings.Contains(header, "YOUR_TOKEN_HERE") && !strings.Contains(header, "your-token") && !strings.Contains(header, "your-key") {
+ return false
+ }
+ for _, field := range strings.Fields(header) {
+ field = strings.Trim(field, `"'`)
+ if idx := strings.LastIndex(field, "="); idx >= 0 {
+ field = field[idx+1:]
+ }
+ if idx := strings.Index(field, ":"); idx >= 0 {
+ if looksLikeCredentialPlaceholder(field[:idx]) || looksLikeCredentialPlaceholder(field[idx+1:]) {
+ return true
+ }
+ }
+ if looksLikeCredentialPlaceholder(field) {
+ return true
+ }
+ }
+ return looksLikeCredentialPlaceholder(header)
+}
+
+func looksLikeCredentialPlaceholder(value string) bool {
+ value = strings.Trim(strings.TrimSpace(value), `"'`)
+ switch value {
+ case "", "", "", "YOUR_TOKEN_HERE", "your-token-here":
+ return true
+ }
+ if len(value) < 3 || value[0] != '<' || value[len(value)-1] != '>' {
+ return false
+ }
+ for _, r := range value[1 : len(value)-1] {
+ if r != '_' && (r < 'A' || r > 'Z') {
+ return false
+ }
+ }
+ return true
+}
+
+func authPlaceholderCredentialError(cfg *config.Config) error {
+ return authPlaceholderCredentialErrorWithSetup(cfg, "human-goat-pp-cli auth set-token ")
+}
+
+func authPlaceholderCredentialErrorWithSetup(cfg *config.Config, setup string) error {
+ location := "config file"
+ if cfg != nil && cfg.Path != "" {
+ location = cfg.Path
+ }
+ return fmt.Errorf("%w configured in %s; set a real token with: %s", ErrPlaceholderCredential, location, setup)
+}
+
+// binaryResponseEnvelope wraps a non-textual success body so it survives the
+// json.RawMessage contract every consumer (CLI output, --json, MCP tools)
+// depends on. Without it, raw bytes (PDF, zip, image) are corrupted by
+// sanitizeJSONResponse and emitted as invalid JSON. The _pp_binary
+// discriminator lets callers and agents detect and base64-decode the payload.
+type binaryResponseEnvelope struct {
+ PPBinary bool `json:"_pp_binary"`
+ ContentType string `json:"content_type"`
+ Encoding string `json:"encoding"`
+ Bytes int `json:"bytes"`
+ Data string `json:"data"`
+}
+
+// isBinaryResponseContentType reports whether a successful response with this
+// Content-Type must be base64-wrapped instead of treated as text/JSON. It is
+// deliberately narrow: JSON, */*, XML, and every text/* type (including
+// text/html, so response_format:html CLIs are untouched) pass through
+// unchanged. Only genuinely binary payloads are wrapped.
+func isBinaryResponseContentType(ct string) bool {
+ mt := strings.ToLower(strings.TrimSpace(ct))
+ if i := strings.IndexByte(mt, ';'); i >= 0 {
+ mt = strings.TrimSpace(mt[:i])
+ }
+ if mt == "" {
+ return false
+ }
+ switch {
+ case mt == "application/json", mt == "text/json", mt == "*/*":
+ return false
+ case strings.HasPrefix(mt, "text/"):
+ return false
+ case strings.HasSuffix(mt, "+json"), strings.HasSuffix(mt, "+xml"):
+ return false
+ case mt == "application/xml", mt == "application/xhtml+xml":
+ return false
+ case mt == "application/javascript", mt == "application/ecmascript",
+ mt == "application/x-www-form-urlencoded", mt == "application/graphql":
+ return false
+ }
+ return true
+}
+
+// wrapBinaryResponse marshals body into a self-describing base64 envelope.
+func wrapBinaryResponse(ct string, body []byte) (json.RawMessage, error) {
+ out, err := json.Marshal(binaryResponseEnvelope{
+ PPBinary: true,
+ ContentType: ct,
+ Encoding: "base64",
+ Bytes: len(body),
+ Data: base64.StdEncoding.EncodeToString(body),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("encoding binary response: %w", err)
+ }
+ return json.RawMessage(out), nil
+}
+
+// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
+// response bodies so that downstream JSON parsing succeeds. For clean JSON
+// responses these checks are no-ops.
+func sanitizeJSONResponse(body []byte) []byte {
+ // UTF-8 BOM
+ body = bytes.TrimPrefix(body, []byte("\xEF\xBB\xBF"))
+
+ // JSONP/XSSI prefixes, ordered longest-first where prefixes overlap
+ prefixes := [][]byte{
+ []byte(")]}'\n"),
+ []byte(")]}'"),
+ []byte("{}&&"),
+ []byte("for(;;);"),
+ []byte("while(1);"),
+ }
+ for _, p := range prefixes {
+ if bytes.HasPrefix(body, p) {
+ body = bytes.TrimPrefix(body, p)
+ body = bytes.TrimLeft(body, " \t\r\n")
+ break
+ }
+ }
+ return body
+}
+
+// maskToken redacts all but the last 4 characters of a token for safe display.
+func maskToken(token string) string {
+ if token == "" {
+ return ""
+ }
+ if len(token) <= 4 {
+ return "****"
+ }
+ return "****" + token[len(token)-4:]
+}
+
+type maskedError struct {
+ msg string
+}
+
+func (e maskedError) Error() string {
+ return e.msg
+}
+
+func (c *Client) displayURL(rawURL string, extraCredentials ...string) string {
+ return c.maskCredentialText(rawURL, extraCredentials...)
+}
+
+func (c *Client) maskError(err error, extraCredentials ...string) error {
+ if err == nil {
+ return nil
+ }
+ raw := err.Error()
+ msg := c.maskCredentialText(raw, extraCredentials...)
+ if msg == raw {
+ return err
+ }
+ return maskedError{msg: msg}
+}
+
+func (c *Client) maskCredentialText(text string, extraCredentials ...string) string {
+ if text == "" {
+ return text
+ }
+ type credentialMask struct {
+ needle string
+ replacement string
+ }
+ var masks []credentialMask
+ seen := map[string]struct{}{}
+ addValue := func(value string) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return
+ }
+ replacement := maskToken(value)
+ addMask := func(needle string) {
+ if needle == "" {
+ return
+ }
+ if _, ok := seen[needle]; ok {
+ return
+ }
+ seen[needle] = struct{}{}
+ masks = append(masks, credentialMask{needle: needle, replacement: replacement})
+ }
+ addMask(value)
+ if escaped := url.QueryEscape(value); escaped != value {
+ addMask(escaped)
+ }
+ if escaped := url.PathEscape(value); escaped != value {
+ addMask(escaped)
+ }
+ }
+ addCredential := func(value string) {
+ value = strings.TrimSpace(value)
+ addValue(value)
+ if _, token, ok := strings.Cut(value, " "); ok {
+ addValue(token)
+ }
+ }
+ for _, value := range extraCredentials {
+ addCredential(value)
+ }
+ if c != nil && c.Config != nil {
+ addCredential(c.Config.AuthHeaderVal)
+ addCredential(c.Config.AuthHeader())
+ addCredential(c.Config.AccessToken)
+ addCredential(c.Config.RefreshToken)
+ addCredential(c.Config.ClientSecret)
+ }
+ sort.SliceStable(masks, func(i, j int) bool {
+ return len(masks[i].needle) > len(masks[j].needle)
+ })
+ masked := text
+ for _, mask := range masks {
+ masked = strings.ReplaceAll(masked, mask.needle, mask.replacement)
+ }
+ return masked
+}
+
+func truncateBody(b []byte) string {
+ const maxBytes = 4096
+ if len(b) <= maxBytes {
+ return string(b)
+ }
+ return strings.ToValidUTF8(string(b[:maxBytes]), "") + "..."
+}
+
+func clientMaxRetries() int {
+ if cliutil.IsVerifyEnv() || cliutil.IsDogfoodEnv() {
+ return 0
+ }
+ return 3
+}
diff --git a/library/productivity/human-goat/internal/client/client_test.go b/library/productivity/human-goat/internal/client/client_test.go
new file mode 100644
index 0000000000..0a6ace44e4
--- /dev/null
+++ b/library/productivity/human-goat/internal/client/client_test.go
@@ -0,0 +1,132 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/config"
+)
+
+func TestTruncateBody(t *testing.T) {
+ t.Parallel()
+
+ const maxBytes = 4096
+
+ cases := []struct {
+ name string
+ input []byte
+ wantLen int
+ wantHasTail bool
+ }{
+ {"empty", nil, 0, false},
+ {"under cap", []byte("hello"), 5, false},
+ {"at cap", bytes.Repeat([]byte("a"), maxBytes), maxBytes, false},
+ {"one over cap", bytes.Repeat([]byte("a"), maxBytes+1), maxBytes + 3, true},
+ {"huge body", bytes.Repeat([]byte("a"), maxBytes*8), maxBytes + 3, true},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := truncateBody(tc.input)
+ if len(got) != tc.wantLen {
+ t.Fatalf("len = %d, want %d", len(got), tc.wantLen)
+ }
+ if tc.wantHasTail && !strings.HasSuffix(got, "...") {
+ t.Fatalf("want trailing %q", "...")
+ }
+ if !tc.wantHasTail && strings.HasSuffix(got, "...") {
+ t.Fatalf("unexpected trailing %q in %q", "...", got)
+ }
+ })
+ }
+}
+
+func TestTruncateBody_UTF8RuneAtBoundary(t *testing.T) {
+ t.Parallel()
+
+ // '€' is 3 bytes (0xE2 0x82 0xAC). Place it so the slice at byte 4096 cuts
+ // mid-rune; strings.ToValidUTF8 should drop the partial rune cleanly rather
+ // than emit U+FFFD or invalid UTF-8.
+ prefix := strings.Repeat("a", 4094)
+ body := []byte(prefix + "€" + strings.Repeat("b", 100))
+ got := truncateBody(body)
+
+ if !utf8.ValidString(got) {
+ t.Fatalf("output is not valid UTF-8")
+ }
+ if strings.ContainsRune(got, utf8.RuneError) {
+ t.Fatalf("output contains replacement rune U+FFFD")
+ }
+ if !strings.HasSuffix(got, "...") {
+ t.Fatalf("want trailing %q", "...")
+ }
+ // Partial rune must be dropped, not replaced: 4094 valid bytes + "...".
+ if want := 4094 + 3; len(got) != want {
+ t.Fatalf("len = %d, want %d (partial rune should be dropped, not replaced)", len(got), want)
+ }
+}
+
+func TestCacheKeyDelimitsSortedQueryParams(t *testing.T) {
+ t.Parallel()
+
+ c := &Client{BaseURL: "https://api.example.test"}
+ collidingTwoParams := c.cacheKey("/titles", map[string]string{
+ "country": "USAformat",
+ "year": "bluray",
+ })
+ collidingThreeParams := c.cacheKey("/titles", map[string]string{
+ "country": "USA",
+ "format": "bluray",
+ "year": "",
+ })
+ if collidingTwoParams == collidingThreeParams {
+ t.Fatalf("cache keys collided for adjacent query params: %q", collidingTwoParams)
+ }
+
+ first := c.cacheKey("/titles", map[string]string{"format": "4k", "country": "USA"})
+ second := c.cacheKey("/titles", map[string]string{"country": "USA", "format": "4k"})
+ if first != second {
+ t.Fatalf("cache key should be deterministic regardless of map order: %q != %q", first, second)
+ }
+}
+
+func TestGetWithHeadersValuesPreservesRepeatedQueryParams(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got := r.URL.Query()["format"]
+ if len(got) != 2 || got[0] != "blu-ray" || got[1] != "4k" {
+ t.Fatalf("format query values = %#v, want [blu-ray 4k]", got)
+ }
+ if got := r.URL.Query().Get("country"); got != "US" {
+ t.Fatalf("country = %q, want US", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ t.Cleanup(server.Close)
+
+ c := New(&config.Config{BaseURL: server.URL}, time.Second, 0)
+ c.HTTPClient = server.Client()
+ c.NoCache = true
+
+ params := url.Values{
+ "format": []string{"blu-ray", "4k"},
+ "country": []string{"US"},
+ }
+ if _, err := c.GetWithHeadersValues(context.Background(), "/titles", params, nil); err != nil {
+ t.Fatalf("GetWithHeadersValues returned error: %v", err)
+ }
+}
diff --git a/library/productivity/human-goat/internal/client/client_verify_short_circuit_test.go b/library/productivity/human-goat/internal/client/client_verify_short_circuit_test.go
new file mode 100644
index 0000000000..38ecd24b9d
--- /dev/null
+++ b/library/productivity/human-goat/internal/client/client_verify_short_circuit_test.go
@@ -0,0 +1,169 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/config"
+)
+
+// recordingRoundTripper counts how many times its RoundTrip method is
+// invoked and returns an empty 200 response. Used by the verify-mode
+// short-circuit tests to assert that the transport layer never dials
+// when the gate fires.
+type recordingRoundTripper struct {
+ calls int
+}
+
+func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ r.calls++
+ return &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewReader([]byte("{}"))),
+ Header: http.Header{},
+ }, nil
+}
+
+// newClientWithRecorder builds a minimal *Client wired to a recording
+// transport. The Client is constructed through New() so the unexported
+// limiter and cacheDir fields are initialized, then HTTPClient is
+// swapped out for one whose Transport records every call.
+func newClientWithRecorder(t *testing.T) (*Client, *recordingRoundTripper) {
+ t.Helper()
+ rec := &recordingRoundTripper{}
+ cfg := &config.Config{BaseURL: "http://example.test"}
+ c := New(cfg, time.Second, 0)
+ c.HTTPClient = &http.Client{Transport: rec}
+ c.NoCache = true
+ return c, rec
+}
+
+// TestClient_VerifyShortCircuit_MutatingVerbs pins the transport-layer
+// short-circuit emitted by client.go.tmpl. Under PRINTING_PRESS_VERIFY=1
+// with no LIVE_HTTP opt-in, every mutating verb (DELETE/POST/PUT/PATCH)
+// must return a synthetic envelope without dialing.
+//
+// A future template edit that drops the gate, narrows the verb list, or
+// removes either env-var check would silently re-open the agent-readiness
+// gap the gate was added to close. This test fails on any of those
+// drifts as part of every printed CLI's own go-test pass.
+func TestClient_VerifyShortCircuit_MutatingVerbs(t *testing.T) {
+ for _, verb := range []string{"DELETE", "POST", "PUT", "PATCH"} {
+ verb := verb
+ t.Run(verb, func(t *testing.T) {
+ t.Setenv("PRINTING_PRESS_VERIFY", "1")
+ // LIVE_HTTP must NOT be set for the short-circuit branch.
+ // Explicit unset defends against a stale value in the parent env.
+ t.Setenv("PRINTING_PRESS_VERIFY_LIVE_HTTP", "")
+ c, rec := newClientWithRecorder(t)
+
+ body, status, err := c.do(context.Background(), verb, "/test", nil, nil, nil)
+ if err != nil {
+ t.Fatalf("do(%s) returned error: %v", verb, err)
+ }
+ if status != http.StatusOK {
+ t.Fatalf("do(%s) status = %d, want %d", verb, status, http.StatusOK)
+ }
+ if rec.calls != 0 {
+ t.Fatalf("do(%s) attempted %d HTTP calls; want 0 (short-circuit)", verb, rec.calls)
+ }
+
+ var env map[string]any
+ if err := json.Unmarshal(body, &env); err != nil {
+ t.Fatalf("envelope is not valid JSON: %v", err)
+ }
+ if got, _ := env["__pp_verify_synthetic__"].(bool); !got {
+ t.Fatalf("envelope must include __pp_verify_synthetic__: true; got %v", env)
+ }
+ if got, _ := env["reason"].(string); got != "verify_short_circuit" {
+ t.Fatalf("envelope reason = %q, want %q", got, "verify_short_circuit")
+ }
+ if got, _ := env["method"].(string); got != verb {
+ t.Fatalf("envelope method = %q, want %q", got, verb)
+ }
+ if got, _ := env["path"].(string); got != "/test" {
+ t.Fatalf("envelope path = %q, want %q", got, "/test")
+ }
+ })
+ }
+}
+
+// TestClient_VerifyShortCircuit_LiveHTTPOptIn pins the verify mock-mode
+// contract: when LIVE_HTTP=1 is set alongside VERIFY=1, the short-circuit
+// does NOT fire and the transport dials. The verifier's httptest mock
+// server depends on this opt-in path to receive mutating requests so its
+// pass/fail assertions can run against real wire-format responses.
+func TestClient_VerifyShortCircuit_LiveHTTPOptIn(t *testing.T) {
+ t.Setenv("PRINTING_PRESS_VERIFY", "1")
+ t.Setenv("PRINTING_PRESS_VERIFY_LIVE_HTTP", "1")
+ c, rec := newClientWithRecorder(t)
+
+ _, _, _ = c.do(context.Background(), "DELETE", "/test", nil, nil, nil)
+
+ if rec.calls < 1 {
+ t.Fatalf("LIVE_HTTP=1 should opt back in to real dial; recorder saw %d calls", rec.calls)
+ }
+}
+
+// TestClient_VerifyShortCircuit_NoEnv pins the operator path: with no
+// verify env vars set, mutating verbs dial normally.
+func TestClient_VerifyShortCircuit_NoEnv(t *testing.T) {
+ // Explicitly unset to defend against test-runner inherited values.
+ t.Setenv("PRINTING_PRESS_VERIFY", "")
+ t.Setenv("PRINTING_PRESS_VERIFY_LIVE_HTTP", "")
+ c, rec := newClientWithRecorder(t)
+
+ _, _, _ = c.do(context.Background(), "DELETE", "/test", nil, nil, nil)
+
+ if rec.calls < 1 {
+ t.Fatalf("no verify env should dial normally; recorder saw %d calls", rec.calls)
+ }
+}
+
+// TestClient_VerifyShortCircuit_GETControl pins that the gate is
+// verb-specific: GET requests are never short-circuited, even under
+// PRINTING_PRESS_VERIFY=1, because they cannot mutate remote state.
+// A regression that broadens isMutatingVerb to include GET would break
+// every CLI's cached-fallback and list/show paths under verify mode.
+func TestClient_VerifyShortCircuit_GETControl(t *testing.T) {
+ t.Setenv("PRINTING_PRESS_VERIFY", "1")
+ t.Setenv("PRINTING_PRESS_VERIFY_LIVE_HTTP", "")
+ c, rec := newClientWithRecorder(t)
+
+ _, _, _ = c.do(context.Background(), "GET", "/test", nil, nil, nil)
+
+ if rec.calls < 1 {
+ t.Fatalf("GET must never short-circuit; recorder saw %d calls", rec.calls)
+ }
+}
+
+// TestClient_VerifyShortCircuit_ReadOnlyPOST pins the doRead bypass: a
+// POST routed through doRead (the path PostQuery* takes for GraphQL
+// queries, JSON-RPC reads, and POST-based search) must NOT short-circuit
+// under PRINTING_PRESS_VERIFY=1, because the operation does not mutate
+// remote state. Without this, an inherited verify env silently breaks
+// every read on a shared-endpoint API.
+func TestClient_VerifyShortCircuit_ReadOnlyPOST(t *testing.T) {
+ for _, verb := range []string{"POST", "PUT", "PATCH"} {
+ verb := verb
+ t.Run(verb, func(t *testing.T) {
+ t.Setenv("PRINTING_PRESS_VERIFY", "1")
+ t.Setenv("PRINTING_PRESS_VERIFY_LIVE_HTTP", "")
+ c, rec := newClientWithRecorder(t)
+
+ _, _, _ = c.doRead(context.Background(), verb, "/test", nil, nil, nil)
+
+ if rec.calls < 1 {
+ t.Fatalf("doRead(%s) must dial through; recorder saw %d calls", verb, rec.calls)
+ }
+ })
+ }
+}
diff --git a/library/productivity/human-goat/internal/client/cookiejar.go b/library/productivity/human-goat/internal/client/cookiejar.go
new file mode 100644
index 0000000000..da7de9d21f
--- /dev/null
+++ b/library/productivity/human-goat/internal/client/cookiejar.go
@@ -0,0 +1,322 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package client
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+)
+
+// cookieJar wraps an http.CookieJar so writes (server Set-Cookie response
+// headers) persist to disk. The CLI loads existing cookies on every client
+// construction and writes back after every request that carries Set-Cookie
+// response headers, so a logged-in session survives across invocations.
+type cookieJar struct {
+ inner http.CookieJar
+ path string
+ mu sync.Mutex
+}
+
+type persistedCookie struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+ Domain string `json:"domain"`
+ Path string `json:"path"`
+ Expires time.Time `json:"expires,omitempty"`
+ Secure bool `json:"secure"`
+ HTTP bool `json:"http_only"`
+}
+
+// LoadCookieJar returns a persistent cookie jar pre-populated from the canonical
+// cookie file on disk. Falls back to an empty in-memory jar when no file exists.
+func LoadCookieJar() http.CookieJar {
+ inner, _ := cookiejar.New(nil)
+ path := cookieJarPath()
+ jar := &cookieJar{inner: inner, path: path}
+ jar.loadFromDisk()
+ return jar
+}
+
+func cookieJarPath() string {
+ dir, err := cliutil.DataDir()
+ if err != nil {
+ return ""
+ }
+ return filepath.Join(dir, "cookies.json")
+}
+
+// looksLikeCookieJar reports whether s is a cookie-jar string ("name=value;
+// name=value") rather than a bare token. The session env var and the browser
+// AccessToken both store the full Cookie header, so the seed is gated on a real
+// "name=value" pair. A bare "=" is too loose: a base64-padded JWT ("eyJ...Q==")
+// contains "=" yet is a single bearer token, and strings.Cut would split it into
+// a bogus {name:"eyJ...Q", value:"="} cookie. So require the first segment to be
+// a valid name=value pair whose name is a legal cookie-name token; that screens
+// out JWT/bearer values while still passing a single legit "name=value" cookie.
+func looksLikeCookieJar(s string) bool {
+ first, _, _ := strings.Cut(s, ";")
+ name, value, ok := strings.Cut(strings.TrimSpace(first), "=")
+ if !ok || value == "" {
+ return false
+ }
+ return isCookieName(strings.TrimSpace(name))
+}
+
+// isCookieName reports whether s is a non-empty RFC 6265 cookie-name token
+// (RFC 2616 token: no controls, spaces, or separators). It additionally rejects
+// "." and "/": these are valid token bytes but appear in JWT/bearer values
+// (header.payload.signature, base64url "/"), which a padded "==" suffix would
+// otherwise sneak past as a name=value pair. Server-set cookie names do not use
+// them in practice, so screening them out separates a real session jar from a
+// bearer token without rejecting legitimate single cookies.
+func isCookieName(s string) bool {
+ if s == "" {
+ return false
+ }
+ for i := 0; i < len(s); i++ {
+ b := s[i]
+ if b <= 0x20 || b >= 0x7f {
+ return false
+ }
+ switch b {
+ case '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"',
+ '/', '[', ']', '?', '=', '{', '}', '.':
+ return false
+ }
+ }
+ return true
+}
+
+// parseCookieJar splits a Cookie-header-style string ("name=value; name=value")
+// into cookies. Pairs without "=" or with an empty name are skipped; values are
+// sanitized to the bytes net/http's jar accepts.
+func parseCookieJar(s string) []*http.Cookie {
+ parts := strings.Split(s, ";")
+ cookies := make([]*http.Cookie, 0, len(parts))
+ for _, part := range parts {
+ name, value, ok := strings.Cut(strings.TrimSpace(part), "=")
+ name = strings.TrimSpace(name)
+ if !ok || name == "" {
+ continue
+ }
+ cookies = append(cookies, &http.Cookie{
+ Name: name,
+ Value: sanitizeCookieValue(strings.TrimSpace(value)),
+ })
+ }
+ return cookies
+}
+
+// SeedCookieJar seeds jar with the cookies in a Cookie-header-style credential
+// string scoped to baseURL's host, so a session captured via the env var or
+// stored in credentials (not the on-disk cookies.json) still rides every
+// request. Seeding the jar (rather than setting a static Cookie header) lets
+// net/http absorb Set-Cookie rotation — Cloudflare __cf_bm, AWS ALB AWSALB —
+// across a multi-request session that a static header would let go stale.
+// A no-op when the credential is empty, is not a cookie-jar string, or baseURL
+// does not parse.
+func SeedCookieJar(jar http.CookieJar, baseURL, cookieStr string) {
+ if jar == nil || !looksLikeCookieJar(cookieStr) {
+ return
+ }
+ u, err := url.Parse(baseURL)
+ if err != nil || u.Host == "" {
+ return
+ }
+ cookies := parseCookieJar(cookieStr)
+ if len(cookies) == 0 {
+ return
+ }
+ // Seed the in-memory jar only — never persist. The wrapper's SetCookies
+ // writes through to cookies.json (persistLocked -> mergeAndWriteCookieRows),
+ // which would clobber fresher rotation-refreshed values (Cloudflare __cf_bm,
+ // AWS ALB AWSALB) already on disk with the stale env/credential ones. Seeding
+ // the inner jar (as loadFromDisk does) keeps the credential live for the
+ // session without touching the persisted set.
+ if cj, ok := jar.(*cookieJar); ok {
+ cj.inner.SetCookies(u, cookies)
+ return
+ }
+ jar.SetCookies(u, cookies)
+}
+
+// sanitizeCookieValue strips bytes that net/http's cookie jar rejects per
+// RFC 6265 (cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E;
+// excludes whitespace, double-quote, comma, semicolon, backslash, and
+// control characters). The jar's SetCookies silently drops invalid bytes
+// and writes a warning to the standard logger on every invocation, which
+// leaks to stderr on every CLI run. Chrome-captured session cookies for
+// some providers (e.g. g_state, attentive_*) carry JSON-shaped values with
+// internal double-quotes; sanitizing once at load time keeps the payload
+// usable (the server stripped the quotes too) and silences the warning.
+// Also removes a single layer of surrounding double-quotes that some
+// Chrome extractions add to non-JSON values.
+func sanitizeCookieValue(value string) string {
+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
+ value = value[1 : len(value)-1]
+ }
+ clean := make([]byte, 0, len(value))
+ for i := 0; i < len(value); i++ {
+ b := value[i]
+ if b == 0x21 || (b >= 0x23 && b <= 0x2B) || (b >= 0x2D && b <= 0x3A) ||
+ (b >= 0x3C && b <= 0x5B) || (b >= 0x5D && b <= 0x7E) {
+ clean = append(clean, b)
+ }
+ }
+ return string(clean)
+}
+
+// WriteCookieJarFromMap persists a name→value cookie set under the given
+// domain. Called from auth.go after Chrome extraction so subsequent requests
+// carry the user's session. Merges with any existing rows so server-pushed
+// cookies stored by persistLocked survive an auth login/refresh.
+func WriteCookieJarFromMap(domain string, cookies map[string]string) error {
+ rows := make([]persistedCookie, 0, len(cookies))
+ for name, value := range cookies {
+ rows = append(rows, persistedCookie{
+ Name: name,
+ Value: sanitizeCookieValue(value),
+ Domain: domain,
+ Path: "/",
+ Secure: true,
+ })
+ }
+ return mergeAndWriteCookieRows(cookieJarPath(), rows)
+}
+
+// mergeAndWriteCookieRows reads the on-disk cookie file, merges the new rows
+// by the (domain, path, name) identity triple (RFC 6265 §5.3), and writes
+// the union back atomically. Shared by the login flow (filtered required
+// cookies) and the http.CookieJar interface (server Set-Cookie response
+// headers) so neither write path silently clobbers the other.
+func mergeAndWriteCookieRows(path string, rows []persistedCookie) error {
+ existing, _ := os.ReadFile(path)
+ var all []persistedCookie
+ _ = json.Unmarshal(existing, &all)
+ idx := make(map[string]int, len(all))
+ for i, r := range all {
+ idx[r.Domain+"|"+r.Path+"|"+r.Name] = i
+ }
+ for _, r := range rows {
+ filtered := all[:0]
+ for _, existing := range all {
+ if shouldReplaceShadowingCookie(existing, r) {
+ delete(idx, existing.Domain+"|"+existing.Path+"|"+existing.Name)
+ continue
+ }
+ filtered = append(filtered, existing)
+ }
+ all = filtered
+ idx = make(map[string]int, len(all))
+ for i, existing := range all {
+ idx[existing.Domain+"|"+existing.Path+"|"+existing.Name] = i
+ }
+ key := r.Domain + "|" + r.Path + "|" + r.Name
+ if i, ok := idx[key]; ok {
+ all[i] = r
+ continue
+ }
+ all = append(all, r)
+ idx[key] = len(all) - 1
+ }
+ data, err := json.MarshalIndent(all, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0o600)
+}
+
+func shouldReplaceShadowingCookie(existing, incoming persistedCookie) bool {
+ if existing.Name != incoming.Name || existing.Path != incoming.Path {
+ return false
+ }
+ return normalizedWWWCookieDomain(existing.Domain) == normalizedWWWCookieDomain(incoming.Domain)
+}
+
+func normalizedWWWCookieDomain(domain string) string {
+ domain = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(domain)), ".")
+ return strings.TrimPrefix(domain, "www.")
+}
+
+func (j *cookieJar) loadFromDisk() {
+ data, err := os.ReadFile(j.path)
+ if err != nil {
+ return
+ }
+ var rows []persistedCookie
+ if err := json.Unmarshal(data, &rows); err != nil {
+ return
+ }
+ // Group cookies by host so a single SetCookies call carries the full
+ // per-host set; the jar keys cookies by host so this matches its shape.
+ byHost := map[string][]*http.Cookie{}
+ for _, r := range rows {
+ host := strings.TrimPrefix(r.Domain, ".")
+ if host == "" {
+ continue
+ }
+ byHost[host] = append(byHost[host], &http.Cookie{
+ Name: r.Name,
+ Value: sanitizeCookieValue(r.Value),
+ Domain: r.Domain,
+ Path: r.Path,
+ Expires: r.Expires,
+ Secure: r.Secure,
+ })
+ }
+ for host, cs := range byHost {
+ u := &url.URL{Scheme: "https", Host: host}
+ j.inner.SetCookies(u, cs)
+ }
+}
+
+// Cookies / SetCookies — http.CookieJar interface; delegates to inner jar and
+// persists writes.
+
+func (j *cookieJar) Cookies(u *url.URL) []*http.Cookie {
+ return j.inner.Cookies(u)
+}
+
+func (j *cookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
+ j.inner.SetCookies(u, cookies)
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ j.persistLocked(u, cookies)
+}
+
+func (j *cookieJar) persistLocked(u *url.URL, cookies []*http.Cookie) {
+ rows := make([]persistedCookie, 0, len(cookies))
+ for _, c := range cookies {
+ domain := c.Domain
+ if domain == "" {
+ domain = "." + u.Host
+ }
+ path := c.Path
+ if path == "" {
+ path = "/"
+ }
+ rows = append(rows, persistedCookie{
+ Name: c.Name,
+ Value: c.Value,
+ Domain: domain,
+ Path: path,
+ Expires: c.Expires,
+ Secure: c.Secure,
+ HTTP: c.HttpOnly,
+ })
+ }
+ _ = mergeAndWriteCookieRows(j.path, rows)
+}
diff --git a/library/productivity/human-goat/internal/cliutil/cliutil_test.go b/library/productivity/human-goat/internal/cliutil/cliutil_test.go
new file mode 100644
index 0000000000..ed80bc03b4
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/cliutil_test.go
@@ -0,0 +1,853 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// ---- CleanText ----
+
+func TestCleanText(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"decodes numeric entity", "The Food Lab's Cookie", "The Food Lab's Cookie"},
+ {"decodes named entity", "AT&T", "AT&T"},
+ {"trims whitespace", " Chicken Tikka ", "Chicken Tikka"},
+ {"empty input", "", ""},
+ {"plain passthrough", "Already clean.", "Already clean."},
+ // Single-pass unescape contract: nested & decodes once to &
+ // but the inner & stays encoded. If a caller needs repeated
+ // unescaping they have a deeper upstream problem.
+ {"single pass on nested entity", "&", "&"},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ if got := CleanText(tc.in); got != tc.want {
+ t.Errorf("CleanText(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestParseStoredTime(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want time.Time
+ }{
+ {
+ name: "rfc3339 nano",
+ in: "2026-04-21T09:02:49.123456789-07:00",
+ want: time.Date(2026, 4, 21, 9, 2, 49, 123456789, time.FixedZone("", -7*60*60)),
+ },
+ {
+ name: "modernc go string",
+ in: "2026-04-21 09:02:49.123456789 -0700 PDT",
+ want: time.Date(2026, 4, 21, 9, 2, 49, 123456789, time.FixedZone("PDT", -7*60*60)),
+ },
+ {
+ name: "blank",
+ in: "",
+ want: time.Time{},
+ },
+ {
+ name: "invalid",
+ in: "not a time",
+ want: time.Time{},
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ got := ParseStoredTime(tc.in)
+ if !got.Equal(tc.want) {
+ t.Fatalf("ParseStoredTime(%q) = %s, want %s", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestAuthErrorHelpers(t *testing.T) {
+ if !LooksLikeAuthError("HTTP 400: missing api_key") {
+ t.Fatal("expected missing api_key to look like an auth error")
+ }
+ if LooksLikeAuthError("HTTP 400: malformed page number") {
+ t.Fatal("unexpected auth classification for non-auth message")
+ }
+
+ got := SanitizeErrorBody("token sk-abcdefghi Bearer abc.def key=secretvalue token=abc.def-ghi")
+ if got != "token [REDACTED] [REDACTED] [REDACTED] [REDACTED]" {
+ t.Fatalf("SanitizeErrorBody redaction = %q", got)
+ }
+}
+
+// ---- FanoutRun ----
+
+func TestFanoutRunAllSucceed(t *testing.T) {
+ sources := []string{"a", "b", "c"}
+ results, errs := FanoutRun(context.Background(), sources,
+ func(s string) string { return s },
+ func(_ context.Context, s string) (string, error) {
+ return s + "!", nil
+ },
+ )
+ if len(errs) != 0 {
+ t.Fatalf("unexpected errors: %+v", errs)
+ }
+ if len(results) != 3 {
+ t.Fatalf("want 3 results, got %d", len(results))
+ }
+ // Source-order contract: results must match input order.
+ for i, r := range results {
+ if r.Source != sources[i] {
+ t.Errorf("results[%d].Source = %q, want %q", i, r.Source, sources[i])
+ }
+ if r.Value != sources[i]+"!" {
+ t.Errorf("results[%d].Value = %q, want %q", i, r.Value, sources[i]+"!")
+ }
+ }
+}
+
+func TestFanoutRunMixed(t *testing.T) {
+ sources := []string{"good", "bad", "good2"}
+ results, errs := FanoutRun(context.Background(), sources,
+ func(s string) string { return s },
+ func(_ context.Context, s string) (string, error) {
+ if s == "bad" {
+ return "", errors.New("intentional failure")
+ }
+ return "ok-" + s, nil
+ },
+ )
+ if len(results) != 2 || len(errs) != 1 {
+ t.Fatalf("want 2 results + 1 error, got %d results + %d errors", len(results), len(errs))
+ }
+ if errs[0].Source != "bad" {
+ t.Errorf("error source = %q, want bad", errs[0].Source)
+ }
+ // Results must stay in source order even with failure in the middle.
+ if results[0].Source != "good" || results[1].Source != "good2" {
+ t.Errorf("results out of source order: %q %q", results[0].Source, results[1].Source)
+ }
+}
+
+func TestFanoutRunCancelledBeforeStart(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // cancelled up front
+
+ sources := []string{"a", "b", "c"}
+ results, errs := FanoutRun(ctx, sources,
+ func(s string) string { return s },
+ func(_ context.Context, s string) (string, error) {
+ return s, nil
+ },
+ )
+ if len(results) != 0 {
+ t.Fatalf("want no results on pre-cancel, got %d", len(results))
+ }
+ // Every source must report ctx.Err() — no silent drops.
+ if len(errs) != len(sources) {
+ t.Fatalf("want %d cancel errors, got %d", len(sources), len(errs))
+ }
+ for i, e := range errs {
+ if e.Source != sources[i] {
+ t.Errorf("errs[%d].Source = %q, want %q", i, e.Source, sources[i])
+ }
+ if !errors.Is(e.Err, context.Canceled) {
+ t.Errorf("errs[%d].Err = %v, want context.Canceled", i, e.Err)
+ }
+ }
+}
+
+func TestFanoutRunBoundedConcurrency(t *testing.T) {
+ // Atomic counter wraps fn to verify max concurrent executions never
+ // exceeds WithConcurrency(n). Directly tests the bounded-channel
+ // contract without relying on runtime.NumGoroutine (too noisy).
+ //
+ // A sync.WaitGroup inside fn blocks each worker until concurrency-many
+ // workers have entered fn, guaranteeing the overlap needed to observe
+ // the max-inflight bound. A busy-wait would be flaky on fast or loaded
+ // hardware; the WaitGroup-based barrier makes the overlap deterministic.
+ var inflight int64
+ var maxInflight int64
+ var barrier sync.WaitGroup
+ barrier.Add(4) // require all 4 workers at the barrier before proceeding
+
+ sources := make([]int, 100)
+ for i := range sources {
+ sources[i] = i
+ }
+ // Only the first 4 calls participate in the barrier so the test doesn't
+ // deadlock — remaining 96 just run normally and contribute to max-inflight
+ // observations.
+ var gated int64
+
+ _, errs := FanoutRun(context.Background(), sources,
+ func(i int) string { return fmt.Sprintf("src-%d", i) },
+ func(_ context.Context, _ int) (struct{}, error) {
+ cur := atomic.AddInt64(&inflight, 1)
+ for {
+ m := atomic.LoadInt64(&maxInflight)
+ if cur <= m || atomic.CompareAndSwapInt64(&maxInflight, m, cur) {
+ break
+ }
+ }
+ if atomic.AddInt64(&gated, 1) <= 4 {
+ barrier.Done()
+ barrier.Wait()
+ }
+ atomic.AddInt64(&inflight, -1)
+ return struct{}{}, nil
+ },
+ WithConcurrency(4),
+ )
+ if len(errs) != 0 {
+ t.Fatalf("unexpected errors: %+v", errs)
+ }
+ if maxInflight > 4 {
+ t.Errorf("max in-flight = %d, want <= 4", maxInflight)
+ }
+ if maxInflight < 4 {
+ // Barrier forces all 4 workers into fn simultaneously. If this
+ // assertion ever fails the bounded-channel contract is broken.
+ t.Errorf("max in-flight = %d, want = 4 (barrier guarantees all 4 workers reach fn together)", maxInflight)
+ }
+}
+
+func TestFanoutRunEmptySources(t *testing.T) {
+ results, errs := FanoutRun(context.Background(), []string{},
+ func(s string) string { return s },
+ func(_ context.Context, s string) (string, error) { return s, nil },
+ )
+ if len(results) != 0 {
+ t.Errorf("empty sources should produce 0 results, got %d", len(results))
+ }
+ if len(errs) != 0 {
+ t.Errorf("empty sources should produce 0 errors, got %d", len(errs))
+ }
+}
+
+func TestFanoutRunAllFail(t *testing.T) {
+ sources := []string{"a", "b", "c"}
+ results, errs := FanoutRun(context.Background(), sources,
+ func(s string) string { return s },
+ func(_ context.Context, _ string) (string, error) {
+ return "", errors.New("boom")
+ },
+ )
+ if len(results) != 0 {
+ t.Errorf("want 0 results when all fail, got %d", len(results))
+ }
+ if len(errs) != 3 {
+ t.Fatalf("want 3 errors, got %d", len(errs))
+ }
+ // Source-order preserved even on all-fail.
+ for i, e := range errs {
+ if e.Source != sources[i] {
+ t.Errorf("errs[%d].Source = %q, want %q", i, e.Source, sources[i])
+ }
+ }
+}
+
+func TestFanoutRunWithConcurrencyClampsZero(t *testing.T) {
+ // WithConcurrency(0) and WithConcurrency(-1) must clamp to 1, not deadlock.
+ for _, n := range []int{0, -1, -100} {
+ sources := []string{"a", "b"}
+ results, errs := FanoutRun(context.Background(), sources,
+ func(s string) string { return s },
+ func(_ context.Context, s string) (string, error) { return s, nil },
+ WithConcurrency(n),
+ )
+ if len(results) != 2 {
+ t.Errorf("WithConcurrency(%d): want 2 results, got %d", n, len(results))
+ }
+ if len(errs) != 0 {
+ t.Errorf("WithConcurrency(%d): unexpected errors: %+v", n, errs)
+ }
+ }
+}
+
+func TestFanoutRunRecoversPanic(t *testing.T) {
+ // An fn that panics must not crash the process. The panicking source
+ // gets a FanoutError; other sources complete normally.
+ sources := []string{"good1", "panic", "good2"}
+ results, errs := FanoutRun(context.Background(), sources,
+ func(s string) string { return s },
+ func(_ context.Context, s string) (string, error) {
+ if s == "panic" {
+ panic("oops")
+ }
+ return "ok-" + s, nil
+ },
+ )
+ if len(results) != 2 {
+ t.Fatalf("want 2 results (good1, good2), got %d", len(results))
+ }
+ if len(errs) != 1 {
+ t.Fatalf("want 1 error (panic), got %d", len(errs))
+ }
+ if errs[0].Source != "panic" {
+ t.Errorf("panic error source = %q, want panic", errs[0].Source)
+ }
+ if errs[0].Err == nil || !containsString(errs[0].Err.Error(), "oops") {
+ t.Errorf("want panic error mentioning 'oops', got %v", errs[0].Err)
+ }
+}
+
+func TestFanoutRunCancelMidFlight(t *testing.T) {
+ // Regression test: cancel while workers are mid-fn. Producer may still
+ // be feeding the bounded channel; some workers are executing fn; others
+ // may have already pulled the next job. The contract: every source ends
+ // up with either a result or an error, never neither and never both.
+ // Run with -race to catch any slot-write race that a naïve implementation
+ // would introduce.
+ ctx, cancel := context.WithCancel(context.Background())
+ sources := make([]int, 30)
+ for i := range sources {
+ sources[i] = i
+ }
+ // Fire cancel after a few fn calls start.
+ var started int64
+ results, errs := FanoutRun(ctx, sources,
+ func(i int) string { return fmt.Sprintf("src-%d", i) },
+ func(c context.Context, _ int) (struct{}, error) {
+ if atomic.AddInt64(&started, 1) == 3 {
+ cancel()
+ }
+ // Brief wait so cancel can propagate and the bounded channel's
+ // drain/feed interaction actually exercises mid-flight state.
+ for j := 0; j < 10000; j++ {
+ _ = j
+ }
+ return struct{}{}, c.Err()
+ },
+ )
+ // Every source must be accounted for exactly once.
+ if total := len(results) + len(errs); total != len(sources) {
+ t.Errorf("results+errs = %d, want %d (every source accounted once)", total, len(sources))
+ }
+ // No double-accounting: a source must not appear in both.
+ seen := map[string]int{}
+ for _, r := range results {
+ seen[r.Source]++
+ }
+ for _, e := range errs {
+ seen[e.Source]++
+ }
+ for src, n := range seen {
+ if n != 1 {
+ t.Errorf("source %q accounted %d times, want exactly 1", src, n)
+ }
+ }
+}
+
+// containsString is a tiny strings.Contains alias so the test file only
+// imports the stdlib packages it otherwise uses.
+func containsString(haystack, needle string) bool {
+ for i := 0; i+len(needle) <= len(haystack); i++ {
+ if haystack[i:i+len(needle)] == needle {
+ return true
+ }
+ }
+ return false
+}
+
+func TestFanoutRunSerialWithConcurrency1(t *testing.T) {
+ // Serial execution: completion order must equal source order because
+ // there's only one worker. Double-checks the source-order contract.
+ sources := []string{"a", "b", "c"}
+ var completionOrder []string
+ var mu sync.Mutex
+
+ results, _ := FanoutRun(context.Background(), sources,
+ func(s string) string { return s },
+ func(_ context.Context, s string) (string, error) {
+ mu.Lock()
+ completionOrder = append(completionOrder, s)
+ mu.Unlock()
+ return s, nil
+ },
+ WithConcurrency(1),
+ )
+ for i, r := range results {
+ if r.Source != sources[i] {
+ t.Errorf("results[%d].Source = %q, want %q", i, r.Source, sources[i])
+ }
+ }
+ for i, s := range completionOrder {
+ if s != sources[i] {
+ t.Errorf("serial completion order[%d] = %q, want %q", i, s, sources[i])
+ }
+ }
+}
+
+// ---- FanoutReportErrors ----
+
+func TestFanoutReportErrorsOrder(t *testing.T) {
+ errs := []FanoutError{
+ {Source: "alpha", Err: errors.New("boom")},
+ {Source: "beta", Err: errors.New("crash\nsecond line")},
+ }
+ var buf bytes.Buffer
+ FanoutReportErrors(&buf, errs)
+ want := "warn: alpha: boom\nwarn: beta: crash\n"
+ if got := buf.String(); got != want {
+ t.Errorf("output mismatch.\n got: %q\nwant: %q", got, want)
+ }
+}
+
+func TestFanoutReportErrorsEmpty(t *testing.T) {
+ var buf bytes.Buffer
+ FanoutReportErrors(&buf, nil)
+ if buf.Len() != 0 {
+ t.Errorf("expected no output for empty errs, got %q", buf.String())
+ }
+}
+
+func TestFanoutReportErrorsTruncates(t *testing.T) {
+ // A long single-line error gets truncated to 120 chars + ellipsis so
+ // stderr stays scan-able when 14 sources all fail with verbose messages.
+ longMsg := ""
+ for i := 0; i < 200; i++ {
+ longMsg += "x"
+ }
+ errs := []FanoutError{
+ {Source: "verbose", Err: errors.New(longMsg)},
+ }
+ var buf bytes.Buffer
+ FanoutReportErrors(&buf, errs)
+ out := buf.String()
+ // "warn: verbose: " is 15 chars; body must be 120 chars + "…" + "\n"
+ if !containsString(out, "…") {
+ t.Errorf("expected truncation ellipsis in output, got %q", out)
+ }
+ if len(out) > 160 {
+ t.Errorf("truncated line should be ~140 chars, got %d (%q)", len(out), out)
+ }
+}
+
+// ---- ProbeReachable ----
+
+// TestProbeReachable_200 asserts that a plain 2xx GET is classified
+// reachable with the right code.
+func TestProbeReachable_200(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("hello"))
+ }))
+ defer srv.Close()
+
+ status, code, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if status != ReachabilityReachable {
+ t.Errorf("status: want %q, got %q", ReachabilityReachable, status)
+ }
+ if code != 200 {
+ t.Errorf("code: want 200, got %d", code)
+ }
+}
+
+// TestProbeReachable_206_Reachable asserts hosts that honor Range
+// (returning 206 Partial Content) are classified reachable.
+func TestProbeReachable_206_Reachable(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusPartialContent)
+ }))
+ defer srv.Close()
+
+ status, code, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if status != ReachabilityReachable {
+ t.Errorf("status: want reachable, got %q", status)
+ }
+ if code != 206 {
+ t.Errorf("code: want 206, got %d", code)
+ }
+}
+
+// TestProbeReachable_416_Reachable asserts hosts that don't support
+// Range (returning 416 Range Not Satisfiable) are still reachable —
+// the headers came back, the host is up. This is the F4 motivating
+// case: HEAD-then-GET probes incorrectly report unreachable here.
+func TestProbeReachable_416_Reachable(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
+ }))
+ defer srv.Close()
+
+ status, code, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if status != ReachabilityReachable {
+ t.Errorf("status: want reachable (416 means headers came back), got %q", status)
+ }
+ if code != 416 {
+ t.Errorf("code: want 416, got %d", code)
+ }
+}
+
+// TestProbeReachable_403_Blocked asserts CDN bot screens (4xx other
+// than 416) are classified blocked, not unreachable. The host is up
+// and refusing this request — a doctor command should render WARN
+// rather than FAIL.
+func TestProbeReachable_403_Blocked(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ }))
+ defer srv.Close()
+
+ status, code, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if status != ReachabilityBlocked {
+ t.Errorf("status: want blocked, got %q", status)
+ }
+ if code != 403 {
+ t.Errorf("code: want 403, got %d", code)
+ }
+}
+
+// TestProbeReachable_NetworkError_Unreachable asserts network-layer
+// failures (DNS, connection refused, timeout) report unreachable with
+// a non-nil err.
+func TestProbeReachable_NetworkError_Unreachable(t *testing.T) {
+ // Use a port that nothing is listening on.
+ status, code, err := ProbeReachable(context.Background(), http.DefaultClient, "http://127.0.0.1:1")
+ if err == nil {
+ t.Fatal("expected non-nil err for unreachable host")
+ }
+ if status != ReachabilityUnreachable {
+ t.Errorf("status: want unreachable, got %q", status)
+ }
+ if code != 0 {
+ t.Errorf("code: want 0 (no response), got %d", code)
+ }
+}
+
+// TestProbeReachable_NilClient_UsesDefault confirms the nil-client
+// guard so doctor commands don't have to plumb an explicit *http.Client
+// when default behavior is fine.
+func TestProbeReachable_NilClient_UsesDefault(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ status, _, err := ProbeReachable(context.Background(), nil, srv.URL)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if status != ReachabilityReachable {
+ t.Errorf("status: want reachable, got %q", status)
+ }
+}
+
+// TestProbeReachable_NilClient_HasTimeout asserts the nil-client
+// fallback uses a bounded-timeout client rather than http.DefaultClient
+// (which has no timeout). Without this, a probe against a slow host
+// could hang indefinitely. The test starts a server that hangs forever
+// and relies on the default 10s timeout to bail out — capped to 12s
+// total so a regression that drops the timeout would surface as a
+// test failure rather than a hung test.
+func TestProbeReachable_NilClient_HasTimeout(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip slow timeout test in -short mode")
+ }
+ hang := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ <-hang // never returns until t.Cleanup closes it
+ }))
+ t.Cleanup(func() {
+ close(hang)
+ srv.Close()
+ })
+
+ done := make(chan struct{})
+ var status string
+ var probeErr error
+ go func() {
+ status, _, probeErr = ProbeReachable(context.Background(), nil, srv.URL)
+ close(done)
+ }()
+ select {
+ case <-done:
+ // Probe returned within the bounded timeout — expected.
+ if probeErr == nil {
+ t.Fatalf("expected timeout err, got nil")
+ }
+ if status != ReachabilityUnreachable {
+ t.Errorf("status: want unreachable on timeout, got %q", status)
+ }
+ case <-time.After(12 * time.Second):
+ t.Fatalf("ProbeReachable hung past defaultProbeTimeout — nil-client fallback may be missing its bounded-timeout guard")
+ }
+}
+
+// TestProbeReachable_SendsRangeHeader confirms the probe sends
+// `Range: bytes=0-1023` so hosts that support Range bound the
+// response body before we even read it.
+func TestProbeReachable_SendsRangeHeader(t *testing.T) {
+ var receivedRange string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ receivedRange = r.Header.Get("Range")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ _, _, err := ProbeReachable(context.Background(), srv.Client(), srv.URL)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if receivedRange != "bytes=0-1023" {
+ t.Errorf("Range header: want %q, got %q", "bytes=0-1023", receivedRange)
+ }
+}
+
+// ---- AdaptiveLimiter / RateLimitError / RetryAfter / Backoff ----
+
+func TestAdaptiveLimiter_NewNilOnNonPositive(t *testing.T) {
+ if NewAdaptiveLimiter(0) != nil {
+ t.Fatal("NewAdaptiveLimiter(0) should return nil")
+ }
+ if NewAdaptiveLimiter(-1) != nil {
+ t.Fatal("NewAdaptiveLimiter(-1) should return nil")
+ }
+}
+
+func TestAdaptiveLimiter_NilSafeMethods(t *testing.T) {
+ var l *AdaptiveLimiter
+ l.Wait()
+ l.OnSuccess()
+ l.OnRateLimit()
+ if got := l.Rate(); got != 0 {
+ t.Errorf("nil limiter Rate() = %v, want 0", got)
+ }
+}
+
+func TestAdaptiveLimiter_RampsUpAfterSuccesses(t *testing.T) {
+ l := NewAdaptiveLimiter(2.0)
+ startRate := l.Rate()
+ for i := 0; i < l.rampAfter; i++ {
+ l.OnSuccess()
+ }
+ if got := l.Rate(); got <= startRate {
+ t.Errorf("Rate() after rampAfter successes = %v, want > %v", got, startRate)
+ }
+}
+
+func TestAdaptiveLimiter_HalvesOnRateLimit(t *testing.T) {
+ l := NewAdaptiveLimiter(8.0)
+ startRate := l.Rate()
+ l.OnRateLimit()
+ got := l.Rate()
+ if got != startRate/2 {
+ t.Errorf("Rate() after OnRateLimit = %v, want %v", got, startRate/2)
+ }
+}
+
+func TestAdaptiveLimiter_FloorsAtHalfRPS(t *testing.T) {
+ l := NewAdaptiveLimiter(2.0)
+ for i := 0; i < 10; i++ {
+ l.OnRateLimit()
+ }
+ if got := l.Rate(); got < 0.5 {
+ t.Errorf("Rate() after many OnRateLimit = %v, want >= 0.5", got)
+ }
+}
+
+func TestAdaptiveLimiter_DoesNotRaiseSubFloorRateOnRateLimit(t *testing.T) {
+ l := NewAdaptiveLimiter(0.3)
+ startRate := l.Rate()
+ l.OnRateLimit()
+ if got := l.Rate(); got > startRate {
+ t.Errorf("Rate() after OnRateLimit = %v, want <= %v", got, startRate)
+ }
+}
+
+func TestAdaptiveLimiter_DoesNotRampBelowFloorAfterRateLimit(t *testing.T) {
+ l := NewAdaptiveLimiter(0.3)
+ floor := l.Rate()
+ l.OnRateLimit()
+ for i := 0; i < l.rampAfter; i++ {
+ l.OnSuccess()
+ }
+ if got := l.Rate(); got < floor {
+ t.Errorf("Rate() after ramping from rate-limit ceiling = %v, want >= %v", got, floor)
+ }
+}
+
+func TestAdaptiveLimiter_WaitEnforcesPacing(t *testing.T) {
+ l := NewAdaptiveLimiter(10.0)
+ l.Wait()
+ start := time.Now()
+ l.Wait()
+ elapsed := time.Since(start)
+ if elapsed < 80*time.Millisecond {
+ t.Errorf("second Wait() took %v, want >= 80ms", elapsed)
+ }
+}
+
+func TestRateLimitError_ErrorMessage(t *testing.T) {
+ cases := []struct {
+ name string
+ err *RateLimitError
+ want string
+ }{
+ {
+ name: "with retry-after and body",
+ err: &RateLimitError{URL: "https://api.example.com/x", RetryAfter: 5 * time.Second, Body: "slow down"},
+ want: "rate limited: HTTP 429 for https://api.example.com/x; retry after 5s: slow down",
+ },
+ {
+ name: "with retry-after no body",
+ err: &RateLimitError{URL: "https://api.example.com/x", RetryAfter: 2 * time.Second},
+ want: "rate limited: HTTP 429 for https://api.example.com/x; retry after 2s",
+ },
+ {
+ name: "no retry-after with body",
+ err: &RateLimitError{URL: "https://api.example.com/x", Body: "later"},
+ want: "rate limited: HTTP 429 for https://api.example.com/x: later",
+ },
+ {
+ name: "no retry-after no body",
+ err: &RateLimitError{URL: "https://api.example.com/x"},
+ want: "rate limited: HTTP 429 for https://api.example.com/x",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.err.Error(); got != tc.want {
+ t.Errorf("Error() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestRateLimitError_ErrorsAs(t *testing.T) {
+ var err error = &RateLimitError{URL: "https://x", RetryAfter: time.Second}
+ var target *RateLimitError
+ if !errors.As(err, &target) {
+ t.Fatal("errors.As should match *RateLimitError")
+ }
+ if target.URL != "https://x" {
+ t.Errorf("target.URL = %q, want %q", target.URL, "https://x")
+ }
+}
+
+func TestRetryAfter_Seconds(t *testing.T) {
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Retry-After", "10")
+ if got := RetryAfter(resp); got != 10*time.Second {
+ t.Errorf("RetryAfter(10) = %v, want 10s", got)
+ }
+}
+
+func TestRetryAfter_HTTPDate(t *testing.T) {
+ future := time.Now().Add(7 * time.Second).UTC().Format(http.TimeFormat)
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Retry-After", future)
+ got := RetryAfter(resp)
+ if got < 5*time.Second || got > 8*time.Second {
+ t.Errorf("RetryAfter(http-date 7s ahead) = %v, want ~7s", got)
+ }
+}
+
+func TestRetryAfter_EpochSeconds(t *testing.T) {
+ future := time.Now().Add(7 * time.Second)
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Retry-After", fmt.Sprint(future.Unix()))
+ got := RetryAfter(resp)
+ if got < 5*time.Second || got > 8*time.Second {
+ t.Errorf("RetryAfter(epoch seconds 7s ahead) = %v, want ~7s", got)
+ }
+}
+
+func TestRetryAfter_EpochMilliseconds(t *testing.T) {
+ future := time.Now().Add(7 * time.Second)
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Retry-After", fmt.Sprint(future.UnixMilli()))
+ got := RetryAfter(resp)
+ if got < 5*time.Second || got > 8*time.Second {
+ t.Errorf("RetryAfter(epoch milliseconds 7s ahead) = %v, want ~7s", got)
+ }
+}
+
+func TestRetryAfter_Cap(t *testing.T) {
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Retry-After", "600")
+ if got := RetryAfter(resp); got != MaxRetryWait {
+ t.Errorf("RetryAfter(600) = %v, want capped at %v", got, MaxRetryWait)
+ }
+}
+
+func TestRetryAfter_Missing(t *testing.T) {
+ resp := &http.Response{Header: http.Header{}}
+ if got := RetryAfter(resp); got != 5*time.Second {
+ t.Errorf("RetryAfter(missing) = %v, want 5s default", got)
+ }
+}
+
+func TestRetryAfter_MalformedFallsBackToDefault(t *testing.T) {
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Retry-After", "not-a-number")
+ if got := RetryAfter(resp); got != 5*time.Second {
+ t.Errorf("RetryAfter(garbage) = %v, want 5s default", got)
+ }
+}
+
+func TestRetryAfter_NilResp(t *testing.T) {
+ if got := RetryAfter(nil); got != 5*time.Second {
+ t.Errorf("RetryAfter(nil) = %v, want 5s default", got)
+ }
+}
+
+func TestBackoff_DoublesPerAttempt(t *testing.T) {
+ cases := []struct {
+ attempt int
+ want time.Duration
+ }{
+ {0, 1 * time.Second},
+ {1, 2 * time.Second},
+ {2, 4 * time.Second},
+ {3, 8 * time.Second},
+ {4, 16 * time.Second},
+ }
+ for _, tc := range cases {
+ if got := Backoff(tc.attempt); got != tc.want {
+ t.Errorf("Backoff(%d) = %v, want %v", tc.attempt, got, tc.want)
+ }
+ }
+}
+
+func TestBackoff_CapsAtMax(t *testing.T) {
+ if got := Backoff(20); got != MaxBackoff {
+ t.Errorf("Backoff(20) = %v, want capped at %v", got, MaxBackoff)
+ }
+}
+
+func TestBackoff_NegativeAttemptClampsToZero(t *testing.T) {
+ if got := Backoff(-3); got != 1*time.Second {
+ t.Errorf("Backoff(-3) = %v, want 1s (clamped to 0)", got)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cliutil/credentials.go b/library/productivity/human-goat/internal/cliutil/credentials.go
new file mode 100644
index 0000000000..73f4ec6a2f
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/credentials.go
@@ -0,0 +1,117 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/pelletier/go-toml/v2"
+)
+
+const credentialsFileName = "credentials.toml"
+
+var (
+ credentialsWarnedMu sync.Mutex
+ credentialsWarned = map[string]struct{}{}
+)
+
+type Credentials struct {
+ AuthHeaderVal string `toml:"auth_header,omitempty"`
+ AccessToken string `toml:"access_token,omitempty"`
+ RefreshToken string `toml:"refresh_token,omitempty"`
+ TokenExpiry time.Time `toml:"token_expiry,omitempty"`
+ ClientID string `toml:"client_id,omitempty"`
+ ClientSecret string `toml:"client_secret,omitempty"`
+}
+
+func credentialsPath() (string, error) {
+ dir, err := DataDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, credentialsFileName), nil
+}
+
+func CredentialsFilePath() (string, error) {
+ return credentialsPath()
+}
+
+func LoadCredentials() (*Credentials, bool, error) {
+ path, err := credentialsPath()
+ if err != nil {
+ return nil, false, err
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, false, nil
+ }
+ warnCredentialsIssue(path, "read", err)
+ return nil, false, nil
+ }
+ var creds Credentials
+ if err := toml.Unmarshal(data, &creds); err != nil {
+ warnCredentialsIssue(path, "parse", err)
+ return nil, false, nil
+ }
+ return &creds, true, nil
+}
+
+func CredentialsFileHasValues() (bool, error) {
+ creds, ok, err := LoadCredentials()
+ if err != nil || !ok {
+ return false, err
+ }
+ return creds.HasValues(), nil
+}
+
+func (c *Credentials) HasValues() bool {
+ if c == nil {
+ return false
+ }
+ return c.AuthHeaderVal != "" ||
+ c.AccessToken != "" ||
+ c.RefreshToken != "" ||
+ c.ClientID != "" ||
+ c.ClientSecret != ""
+}
+
+func warnCredentialsIssue(path, action string, err error) {
+ key := path + "\x00" + action
+ credentialsWarnedMu.Lock()
+ defer credentialsWarnedMu.Unlock()
+ if _, ok := credentialsWarned[key]; ok {
+ return
+ }
+ credentialsWarned[key] = struct{}{}
+ fmt.Fprintf(os.Stderr, "warning: credentials %s failed for %s: %v\n", action, path, err)
+}
+
+func SaveCredentials(creds *Credentials) error {
+ path, err := credentialsPath()
+ if err != nil {
+ return err
+ }
+ data, err := toml.Marshal(creds)
+ if err != nil {
+ return fmt.Errorf("marshaling credentials: %w", err)
+ }
+ return AtomicWritePrivateFile(path, data, 0o600, 0o700)
+}
+
+func RemoveCredentials() error {
+ path, err := credentialsPath()
+ if err != nil {
+ return err
+ }
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("removing credentials: %w", err)
+ }
+ return nil
+}
diff --git a/library/productivity/human-goat/internal/cliutil/credentials_test.go b/library/productivity/human-goat/internal/cliutil/credentials_test.go
new file mode 100644
index 0000000000..93fc40233e
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/credentials_test.go
@@ -0,0 +1,342 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil_test
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/config"
+)
+
+func resetCredentialEnv(t *testing.T) (home, configPath string) {
+ t.Helper()
+ home = t.TempDir()
+ t.Setenv("HOME", home)
+ for _, name := range []string{
+ "HUMAN_GOAT_CONFIG",
+ "HUMAN_GOAT_CONFIG_DIR",
+ "HUMAN_GOAT_DATA_DIR",
+ "HUMAN_GOAT_STATE_DIR",
+ "HUMAN_GOAT_CACHE_DIR",
+ "HUMAN_GOAT_HOME",
+ "XDG_CONFIG_HOME",
+ "XDG_DATA_HOME",
+ "XDG_STATE_HOME",
+ "XDG_CACHE_HOME",
+ } {
+ t.Setenv(name, "")
+ }
+ if restore, err := cliutil.SetHomeOverride(""); err == nil {
+ t.Cleanup(restore)
+ } else {
+ t.Fatalf("reset home override: %v", err)
+ }
+ return home, filepath.Join(home, ".config", "human-goat-pp-cli", "config.toml")
+}
+
+func TestCredentialsFileWinsWhenLegacyConfigAlsoHasSecrets(t *testing.T) {
+ _, configPath := resetCredentialEnv(t)
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
+ t.Fatalf("mkdir config: %v", err)
+ }
+ if err := os.WriteFile(configPath, []byte("base_url = \"https://legacy.example\"\n"+legacyCredentialTOML("legacy-secret")), 0o600); err != nil {
+ t.Fatalf("write legacy config: %v", err)
+ }
+ if err := cliutil.SaveCredentials(testCredentials("data-secret")); err != nil {
+ t.Fatalf("SaveCredentials() error = %v", err)
+ }
+
+ cfg, err := config.Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ assertConfigCredential(t, cfg, "data-secret")
+ if got := cfg.AuthHeader(); !strings.Contains(got, "data-secret") || strings.Contains(got, "legacy-secret") {
+ t.Fatalf("AuthHeader() = %q, want credentials-file value and not legacy value", got)
+ }
+}
+
+func TestCorruptCredentialsFallsBackToLegacyConfig(t *testing.T) {
+ home, configPath := resetCredentialEnv(t)
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
+ t.Fatalf("mkdir config: %v", err)
+ }
+ if err := os.WriteFile(configPath, []byte("base_url = \"https://legacy.example\"\n"+legacyCredentialTOML("legacy-secret")), 0o600); err != nil {
+ t.Fatalf("write legacy config: %v", err)
+ }
+ credentialsPath := filepath.Join(home, ".local", "share", "human-goat-pp-cli", "credentials.toml")
+ if err := os.MkdirAll(filepath.Dir(credentialsPath), 0o700); err != nil {
+ t.Fatalf("mkdir credentials dir: %v", err)
+ }
+ if err := os.WriteFile(credentialsPath, []byte("not = [toml"), 0o600); err != nil {
+ t.Fatalf("write corrupt credentials: %v", err)
+ }
+
+ stderr := captureCredentialStderr(t, func() {
+ cfg, err := config.Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ assertConfigCredential(t, cfg, "legacy-secret")
+ if got := cfg.AuthHeader(); !strings.Contains(got, "legacy-secret") {
+ t.Fatalf("AuthHeader() = %q, want legacy credential", got)
+ }
+ })
+ if !strings.Contains(stderr, credentialsPath) || !strings.Contains(stderr, "parse") {
+ t.Fatalf("stderr %q does not mention corrupt credentials path and parse action", stderr)
+ }
+}
+
+func TestEmptyCredentialsFileDoesNotClearLegacyConfig(t *testing.T) {
+ home, configPath := resetCredentialEnv(t)
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
+ t.Fatalf("mkdir config: %v", err)
+ }
+ if err := os.WriteFile(configPath, []byte("base_url = \"https://legacy.example\"\n"+legacyCredentialTOML("legacy-secret")), 0o600); err != nil {
+ t.Fatalf("write legacy config: %v", err)
+ }
+ credentialsPath := filepath.Join(home, ".local", "share", "human-goat-pp-cli", "credentials.toml")
+ if err := os.MkdirAll(filepath.Dir(credentialsPath), 0o700); err != nil {
+ t.Fatalf("mkdir credentials dir: %v", err)
+ }
+ if err := os.WriteFile(credentialsPath, []byte(""), 0o600); err != nil {
+ t.Fatalf("write empty credentials: %v", err)
+ }
+
+ cfg, err := config.Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ assertConfigCredential(t, cfg, "legacy-secret")
+ if got := cfg.AuthHeader(); !strings.Contains(got, "legacy-secret") {
+ t.Fatalf("AuthHeader() = %q, want legacy credential", got)
+ }
+}
+
+func TestAuthWriteMigratesLegacyConfigToCredentialsOnly(t *testing.T) {
+ _, configPath := resetCredentialEnv(t)
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
+ t.Fatalf("mkdir config: %v", err)
+ }
+ if err := os.WriteFile(configPath, []byte("base_url = \"https://settings.example\"\n"+legacyCredentialTOML("legacy-secret")), 0o600); err != nil {
+ t.Fatalf("write legacy config: %v", err)
+ }
+
+ cfg, err := config.Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ writeConfigCredential(t, cfg, "new-secret")
+
+ creds, ok, err := cliutil.LoadCredentials()
+ if err != nil {
+ t.Fatalf("LoadCredentials() error = %v", err)
+ }
+ if !ok {
+ t.Fatalf("credentials file not found after auth write")
+ }
+ assertCredentialsValue(t, creds, "new-secret")
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("read config: %v", err)
+ }
+ text := string(data)
+ if strings.Contains(text, "legacy-secret") || strings.Contains(text, "new-secret") || strings.Contains(text, legacyCredentialKey()) {
+ t.Fatalf("config.toml re-persisted secret material:\n%s", text)
+ }
+ if !strings.Contains(text, "https://settings.example") {
+ t.Fatalf("config.toml lost non-secret settings:\n%s", text)
+ }
+}
+
+func TestAuthWriteScrubsLegacyConfigWhenRelocated(t *testing.T) {
+ home, defaultConfigPath := resetCredentialEnv(t)
+ if err := os.MkdirAll(filepath.Dir(defaultConfigPath), 0o700); err != nil {
+ t.Fatalf("mkdir config: %v", err)
+ }
+ if err := os.WriteFile(defaultConfigPath, []byte("base_url = \"https://settings.example\"\n"+legacyCredentialTOML("legacy-secret")), 0o600); err != nil {
+ t.Fatalf("write legacy config: %v", err)
+ }
+
+ newConfigDir := filepath.Join(home, "new", "config")
+ newDataDir := filepath.Join(home, "new", "data")
+ t.Setenv("XDG_CONFIG_HOME", newConfigDir)
+ t.Setenv("XDG_DATA_HOME", newDataDir)
+
+ cfg, err := config.Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ writeConfigCredential(t, cfg, "new-secret")
+
+ // Legacy config should be scrubbed of secrets.
+ legacyData, err := os.ReadFile(defaultConfigPath)
+ if err != nil {
+ t.Fatalf("read legacy config: %v", err)
+ }
+ legacyText := string(legacyData)
+ if strings.Contains(legacyText, "legacy-secret") || strings.Contains(legacyText, "new-secret") || strings.Contains(legacyText, legacyCredentialKey()) {
+ t.Fatalf("legacy config.toml still contains secret material after relocation scrub:\n%s", legacyText)
+ }
+ if !strings.Contains(legacyText, "https://settings.example") {
+ t.Fatalf("legacy config.toml lost non-secret settings:\n%s", legacyText)
+ }
+
+ // Active config at relocated path should also be secret-free.
+ activeConfigPath := filepath.Join(newConfigDir, "human-goat-pp-cli", "config.toml")
+ activeData, err := os.ReadFile(activeConfigPath)
+ if err != nil {
+ t.Fatalf("read active config: %v", err)
+ }
+ activeText := string(activeData)
+ if strings.Contains(activeText, "new-secret") || strings.Contains(activeText, legacyCredentialKey()) {
+ t.Fatalf("active config.toml persisted secret material:\n%s", activeText)
+ }
+ if !strings.Contains(activeText, "https://settings.example") {
+ t.Fatalf("active config.toml lost non-secret settings:\n%s", activeText)
+ }
+
+ // Credentials file should exist in the new data dir.
+ credsPath := filepath.Join(newDataDir, "human-goat-pp-cli", "credentials.toml")
+ if _, err := os.Stat(credsPath); err != nil {
+ t.Fatalf("credentials file not found at relocated data dir: %v", err)
+ }
+}
+
+func TestClearTokensFromBothStateClearsCredentialsAndLegacy(t *testing.T) {
+ _, configPath := resetCredentialEnv(t)
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
+ t.Fatalf("mkdir config: %v", err)
+ }
+ if err := os.WriteFile(configPath, []byte("base_url = \"https://settings.example\"\n"+legacyCredentialTOML("legacy-secret")), 0o600); err != nil {
+ t.Fatalf("write legacy config: %v", err)
+ }
+ if err := cliutil.SaveCredentials(testCredentials("data-secret")); err != nil {
+ t.Fatalf("SaveCredentials() error = %v", err)
+ }
+ cfg, err := config.Load("")
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if err := cfg.ClearTokens(); err != nil {
+ t.Fatalf("ClearTokens() error = %v", err)
+ }
+ if _, ok, err := cliutil.LoadCredentials(); err != nil || ok {
+ t.Fatalf("LoadCredentials() after ClearTokens = ok %v err %v, want no file", ok, err)
+ }
+ reloaded, err := config.Load("")
+ if err != nil {
+ t.Fatalf("reload config: %v", err)
+ }
+ assertConfigCredential(t, reloaded, "")
+}
+
+func TestConcurrentCredentialWritersLeaveParseableCredentials(t *testing.T) {
+ resetCredentialEnv(t)
+ cfgA := &config.Config{Path: filepath.Join(t.TempDir(), "a.toml")}
+ cfgB := &config.Config{Path: filepath.Join(t.TempDir(), "b.toml")}
+ var wg sync.WaitGroup
+ for _, tc := range []struct {
+ cfg *config.Config
+ token string
+ }{
+ {cfgA, "alpha-secret"},
+ {cfgB, "bravo-secret"},
+ } {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if err := saveConfigCredential(tc.cfg, tc.token); err != nil {
+ t.Errorf("save credential %s: %v", tc.token, err)
+ }
+ }()
+ }
+ wg.Wait()
+ creds, ok, err := cliutil.LoadCredentials()
+ if err != nil {
+ t.Fatalf("LoadCredentials() error = %v", err)
+ }
+ if !ok {
+ t.Fatalf("credentials file missing after concurrent writes")
+ }
+ got := credentialValue(creds)
+ if got != "alpha-secret" && got != "bravo-secret" {
+ t.Fatalf("credential value = %q, want one complete writer", got)
+ }
+}
+
+func testCredentials(token string) *cliutil.Credentials {
+ creds := &cliutil.Credentials{}
+ setCredentialValue(creds, token)
+ return creds
+}
+
+func legacyCredentialKey() string {
+ return "access_token"
+}
+
+func legacyCredentialTOML(token string) string {
+ return legacyCredentialKey() + " = \"" + token + "\"\n"
+}
+
+func writeConfigCredential(t *testing.T, cfg *config.Config, token string) {
+ t.Helper()
+ if err := saveConfigCredential(cfg, token); err != nil {
+ t.Fatalf("SaveTokens() error = %v", err)
+ }
+}
+
+func saveConfigCredential(cfg *config.Config, token string) error {
+ return cfg.SaveTokens("client-id", "client-secret", token, "refresh-token", time.Unix(123, 0))
+}
+
+func assertConfigCredential(t *testing.T, cfg *config.Config, want string) {
+ t.Helper()
+ if got := configCredentialValue(cfg); got != want {
+ t.Fatalf("config credential = %q, want %q", got, want)
+ }
+}
+
+func configCredentialValue(cfg *config.Config) string {
+ return cfg.AccessToken
+}
+
+func assertCredentialsValue(t *testing.T, creds *cliutil.Credentials, want string) {
+ t.Helper()
+ if got := credentialValue(creds); got != want {
+ t.Fatalf("credentials value = %q, want %q", got, want)
+ }
+}
+
+func credentialValue(creds *cliutil.Credentials) string {
+ return creds.AccessToken
+}
+
+func setCredentialValue(creds *cliutil.Credentials, token string) {
+ creds.AccessToken = token
+}
+
+func captureCredentialStderr(t *testing.T, fn func()) string {
+ t.Helper()
+ old := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("pipe: %v", err)
+ }
+ os.Stderr = w
+ fn()
+ _ = w.Close()
+ os.Stderr = old
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
diff --git a/library/productivity/human-goat/internal/cliutil/duration.go b/library/productivity/human-goat/internal/cliutil/duration.go
new file mode 100644
index 0000000000..0469200d45
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/duration.go
@@ -0,0 +1,41 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ParseDurationLoose parses a duration string, extending Go's
+// time.ParseDuration with day ("d") and week ("w") suffixes so hand-coded
+// time-window flags accept the same 7d/30d/1w/4w shorthand the framework's
+// `sync --since` advertises. Everything else (h, m, s, ms, us, ns, and
+// compound forms like "1h30m") falls through to time.ParseDuration unchanged.
+//
+// The day/week suffix takes an integer count (e.g. "7d", "-2w"); fractional
+// windows should use a stdlib unit ("36h" rather than "1.5d"). Hand-coded
+// duration flags should declare a StringVar (not DurationVar) and post-parse
+// with this helper.
+func ParseDurationLoose(s string) (time.Duration, error) {
+ trimmed := strings.TrimSpace(s)
+ if n := len(trimmed); n >= 2 {
+ switch unit := trimmed[n-1]; unit {
+ case 'd', 'w':
+ // No stdlib duration unit ends in 'd' or 'w', so a trailing
+ // d/w is unambiguously our extension. If the count is not a
+ // plain integer, fall through to time.ParseDuration to surface
+ // its standard error.
+ if count, err := strconv.ParseInt(trimmed[:n-1], 10, 64); err == nil {
+ hoursPerUnit := int64(24)
+ if unit == 'w' {
+ hoursPerUnit = 24 * 7
+ }
+ return time.Duration(count) * time.Hour * time.Duration(hoursPerUnit), nil
+ }
+ }
+ }
+ return time.ParseDuration(trimmed)
+}
diff --git a/library/productivity/human-goat/internal/cliutil/duration_test.go b/library/productivity/human-goat/internal/cliutil/duration_test.go
new file mode 100644
index 0000000000..a48f659698
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/duration_test.go
@@ -0,0 +1,56 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseDurationLoose(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ input string
+ want time.Duration
+ wantErr bool
+ }{
+ {"days", "7d", 7 * 24 * time.Hour, false},
+ {"thirty days", "30d", 30 * 24 * time.Hour, false},
+ {"two weeks window", "14d", 14 * 24 * time.Hour, false},
+ {"weeks", "4w", 4 * 7 * 24 * time.Hour, false},
+ {"one week", "1w", 7 * 24 * time.Hour, false},
+ {"zero days", "0d", 0, false},
+ {"negative days", "-2d", -2 * 24 * time.Hour, false},
+ {"surrounding spaces", " 7d ", 7 * 24 * time.Hour, false},
+ {"stdlib hours", "24h", 24 * time.Hour, false},
+ {"stdlib minutes", "30m", 30 * time.Minute, false},
+ {"stdlib compound", "1h30m", 90 * time.Minute, false},
+ {"stdlib millis", "500ms", 500 * time.Millisecond, false},
+ {"fractional day falls through to stdlib error", "1.5d", 0, true},
+ {"bare unit", "d", 0, true},
+ {"empty", "", 0, true},
+ {"garbage", "abc", 0, true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got, err := ParseDurationLoose(tc.input)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("ParseDurationLoose(%q) = %v, want error", tc.input, got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("ParseDurationLoose(%q) unexpected error: %v", tc.input, err)
+ }
+ if got != tc.want {
+ t.Fatalf("ParseDurationLoose(%q) = %v, want %v", tc.input, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/library/productivity/human-goat/internal/cliutil/extractnumber.go b/library/productivity/human-goat/internal/cliutil/extractnumber.go
new file mode 100644
index 0000000000..c091a80fcf
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/extractnumber.go
@@ -0,0 +1,67 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "encoding/json"
+ "strconv"
+)
+
+// ExtractNumber reads a numeric value from a json.RawMessage map, accepting
+// either a JSON number (1.91) or a JSON-encoded string ("1.91"). Returns
+// (value, ok). ok=false when the key is missing, JSON null, an empty raw
+// payload, an empty string, or unparseable as a float64.
+//
+// Why this exists: many streaming and market-data APIs (Binance, Coinbase,
+// Kraken, Stripe *_decimal fields, vendor-specific WebSocket feeds) encode
+// numeric values as JSON-encoded strings. json.Unmarshal of "1.91" into a
+// float64 struct field silently zeroes the field — no error is returned.
+// Combined with a NULL-on-zero pattern (common for SQL aggregation
+// correctness) every such field becomes a SQL NULL and downstream
+// aggregations return empty. The failure is silent at every structural
+// test layer; only frame-level inspection reveals it.
+//
+// Prefer this helper over hand-rolled float64 struct fields when authoring
+// streaming frame normalizers or any decoder whose wire shape is not
+// fully fixed by the spec.
+func ExtractNumber(probe map[string]json.RawMessage, key string) (float64, bool) {
+ raw, present := probe[key]
+ if !present || len(raw) == 0 || string(raw) == "null" {
+ return 0, false
+ }
+ var f float64
+ if err := json.Unmarshal(raw, &f); err == nil {
+ return f, true
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+ if v, perr := strconv.ParseFloat(s, 64); perr == nil {
+ return v, true
+ }
+ }
+ return 0, false
+}
+
+// ExtractInt is the integer companion of ExtractNumber. It accepts a JSON
+// integer (123) or a JSON-encoded integer string ("123"). Returns
+// (value, ok). ok=false when the key is missing, null, empty, or
+// unparseable as an int64. A non-integer JSON number (1.5) is rejected
+// rather than truncated.
+func ExtractInt(probe map[string]json.RawMessage, key string) (int64, bool) {
+ raw, present := probe[key]
+ if !present || len(raw) == 0 || string(raw) == "null" {
+ return 0, false
+ }
+ var n int64
+ if err := json.Unmarshal(raw, &n); err == nil {
+ return n, true
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+ if v, perr := strconv.ParseInt(s, 10, 64); perr == nil {
+ return v, true
+ }
+ }
+ return 0, false
+}
diff --git a/library/productivity/human-goat/internal/cliutil/extractnumber_test.go b/library/productivity/human-goat/internal/cliutil/extractnumber_test.go
new file mode 100644
index 0000000000..8bf7c0d4e5
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/extractnumber_test.go
@@ -0,0 +1,117 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "encoding/json"
+ "math"
+ "testing"
+)
+
+func TestExtractNumber(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ input string
+ key string
+ wantV float64
+ wantOk bool
+ }{
+ {"json number", `{"price":1.91}`, "price", 1.91, true},
+ {"json integer", `{"price":42}`, "price", 42, true},
+ {"json zero", `{"price":0}`, "price", 0, true},
+ {"json negative number", `{"price":-3.5}`, "price", -3.5, true},
+ {"string-encoded number", `{"price":"1.91"}`, "price", 1.91, true},
+ {"string-encoded integer", `{"qty":"100"}`, "qty", 100, true},
+ {"string-encoded negative", `{"x":"-3.5"}`, "x", -3.5, true},
+ {"string-encoded exponent", `{"x":"1e2"}`, "x", 100, true},
+ {"null", `{"price":null}`, "price", 0, false},
+ {"missing key", `{}`, "price", 0, false},
+ {"empty string", `{"price":""}`, "price", 0, false},
+ {"unparseable string", `{"price":"abc"}`, "price", 0, false},
+ {"bool true", `{"price":true}`, "price", 0, false},
+ {"object", `{"price":{"a":1}}`, "price", 0, false},
+ {"array", `{"price":[1,2]}`, "price", 0, false},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ var probe map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(tc.input), &probe); err != nil {
+ t.Fatalf("unmarshal probe: %v", err)
+ }
+ got, ok := ExtractNumber(probe, tc.key)
+ if ok != tc.wantOk {
+ t.Fatalf("ExtractNumber ok = %v, want %v", ok, tc.wantOk)
+ }
+ if ok && math.Abs(got-tc.wantV) > 1e-9 {
+ t.Fatalf("ExtractNumber value = %v, want %v", got, tc.wantV)
+ }
+ })
+ }
+}
+
+func TestExtractNumber_NilProbe(t *testing.T) {
+ t.Parallel()
+
+ got, ok := ExtractNumber(nil, "anything")
+ if ok || got != 0 {
+ t.Fatalf("ExtractNumber(nil, ...) = (%v, %v), want (0, false)", got, ok)
+ }
+}
+
+func TestExtractInt(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ input string
+ key string
+ wantV int64
+ wantOk bool
+ }{
+ {"json integer", `{"id":123}`, "id", 123, true},
+ {"json zero", `{"id":0}`, "id", 0, true},
+ {"json negative", `{"id":-5}`, "id", -5, true},
+ {"string-encoded int", `{"id":"123"}`, "id", 123, true},
+ {"string-encoded negative", `{"id":"-5"}`, "id", -5, true},
+ {"null", `{"id":null}`, "id", 0, false},
+ {"missing key", `{}`, "id", 0, false},
+ {"empty string", `{"id":""}`, "id", 0, false},
+ {"unparseable string", `{"id":"abc"}`, "id", 0, false},
+ {"float rejected", `{"id":1.5}`, "id", 0, false},
+ {"float string rejected", `{"id":"1.5"}`, "id", 0, false},
+ {"bool", `{"id":true}`, "id", 0, false},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ var probe map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(tc.input), &probe); err != nil {
+ t.Fatalf("unmarshal probe: %v", err)
+ }
+ got, ok := ExtractInt(probe, tc.key)
+ if ok != tc.wantOk {
+ t.Fatalf("ExtractInt ok = %v, want %v", ok, tc.wantOk)
+ }
+ if ok && got != tc.wantV {
+ t.Fatalf("ExtractInt value = %v, want %v", got, tc.wantV)
+ }
+ })
+ }
+}
+
+func TestExtractInt_NilProbe(t *testing.T) {
+ t.Parallel()
+
+ got, ok := ExtractInt(nil, "anything")
+ if ok || got != 0 {
+ t.Fatalf("ExtractInt(nil, ...) = (%v, %v), want (0, false)", got, ok)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cliutil/fanout.go b/library/productivity/human-goat/internal/cliutil/fanout.go
new file mode 100644
index 0000000000..273d3370a0
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/fanout.go
@@ -0,0 +1,202 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package cliutil contains shared helpers emitted into every generated CLI
+// by the Printing Press. Helpers live in their own package (not in package
+// cli) to avoid symbol collisions with agent-authored commands in package
+// cli. Callers import as `cliutil` and invoke `cliutil.FanoutRun(...)`,
+// `cliutil.CleanText(...)`, etc.
+package cliutil
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+)
+
+// FanoutError represents one source's failure from a FanoutRun call.
+// Source identifies which input produced the error; Err is the error returned
+// by the caller's fn.
+type FanoutError struct {
+ Source string
+ Err error
+}
+
+// FanoutResult pairs a successful fn return value with its source name so
+// callers can iterate results without a separate source lookup.
+type FanoutResult[T any] struct {
+ Source string
+ Value T
+}
+
+// FanoutOption configures a FanoutRun call. Use the With* constructors.
+type FanoutOption func(*fanoutOptions)
+
+type fanoutOptions struct {
+ concurrency int
+}
+
+// defaultFanoutConcurrency is the worker count when the caller passes no
+// WithConcurrency option. 4 matches the existing sync.go worker-pool idiom
+// and is safe for scraping CLIs where per-host 429 pressure is real.
+const defaultFanoutConcurrency = 4
+
+// WithConcurrency overrides the default worker count for a single FanoutRun
+// call. Use higher values for fan-outs without external rate limits; keep
+// the default (4) for scraping CLIs. Values below 1 are clamped to 1.
+func WithConcurrency(n int) FanoutOption {
+ return func(o *fanoutOptions) {
+ if n < 1 {
+ n = 1
+ }
+ o.concurrency = n
+ }
+}
+
+// FanoutRun invokes fn concurrently for each source and collects successful
+// results plus per-source errors. It never returns a top-level error and
+// recovers panics from fn as per-source FanoutErrors — partial failures
+// surface via the returned errors slice, which should be piped to
+// FanoutReportErrors so no source is silently dropped.
+//
+// Contract:
+// - Workers respect ctx: on ctx.Done() they stop pulling new jobs, and
+// in-flight fn calls receive the cancelled ctx.
+// - Unpulled sources produce a FanoutError{Err: ctx.Err()} so reporting
+// stays complete — cancel never silently drops a source.
+// - Errors are collected by source index and returned in source order,
+// not completion order, so FanoutReportErrors output is deterministic
+// across runs.
+// - The jobs channel is bounded at 2*concurrency so large source lists
+// don't buffer one goroutine per source.
+//
+// Per-source rate limiting is the caller's responsibility. Wrap fn with a
+// limiter (e.g., golang.org/x/time/rate) if you're fanning out to sites
+// that enforce per-host throttles; naïve scrape fan-out triggers 429s.
+func FanoutRun[S, T any](
+ ctx context.Context,
+ sources []S,
+ name func(S) string,
+ fn func(context.Context, S) (T, error),
+ opts ...FanoutOption,
+) ([]FanoutResult[T], []FanoutError) {
+ cfg := fanoutOptions{concurrency: defaultFanoutConcurrency}
+ for _, o := range opts {
+ o(&cfg)
+ }
+ if cfg.concurrency < 1 {
+ cfg.concurrency = 1
+ }
+
+ // Parallel slices indexed by source position so output stays in source
+ // order regardless of completion order. Using pointers lets us detect
+ // "no result and no error" (shouldn't happen but is a defensive signal).
+ type slot struct {
+ result *FanoutResult[T]
+ err *FanoutError
+ }
+ slots := make([]slot, len(sources))
+
+ type job struct{ idx int }
+ jobs := make(chan job, cfg.concurrency*2)
+
+ var wg sync.WaitGroup
+ for w := 0; w < cfg.concurrency; w++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := range jobs {
+ idx := j.idx
+ func() {
+ // Recover panics from fn so one bad source doesn't kill
+ // the whole process. The panic becomes a per-source
+ // FanoutError alongside regular errors.
+ defer func() {
+ if r := recover(); r != nil {
+ slots[idx].err = &FanoutError{
+ Source: name(sources[idx]),
+ Err: fmt.Errorf("panic in fanout fn: %v", r),
+ }
+ }
+ }()
+ // Respect cancellation: if ctx is already done, record
+ // the cancel error rather than running fn with a
+ // useless context.
+ if err := ctx.Err(); err != nil {
+ slots[idx].err = &FanoutError{Source: name(sources[idx]), Err: err}
+ return
+ }
+ val, err := fn(ctx, sources[idx])
+ if err != nil {
+ slots[idx].err = &FanoutError{Source: name(sources[idx]), Err: err}
+ } else {
+ v := val
+ slots[idx].result = &FanoutResult[T]{Source: name(sources[idx]), Value: v}
+ }
+ }()
+ }
+ }()
+ }
+
+ // Feed jobs, but stop feeding if ctx cancels so unpulled sources get a
+ // ctx.Err() FanoutError rather than being silently dropped.
+ func() {
+ defer close(jobs)
+ for i := range sources {
+ select {
+ case <-ctx.Done():
+ // Mark this and all remaining sources as cancelled, then stop.
+ for j := i; j < len(sources); j++ {
+ slots[j].err = &FanoutError{Source: name(sources[j]), Err: ctx.Err()}
+ }
+ return
+ case jobs <- job{idx: i}:
+ }
+ }
+ }()
+
+ wg.Wait()
+
+ results := make([]FanoutResult[T], 0, len(sources))
+ errs := make([]FanoutError, 0, len(slots))
+ for _, s := range slots {
+ if s.result != nil {
+ results = append(results, *s.result)
+ }
+ if s.err != nil {
+ errs = append(errs, *s.err)
+ }
+ }
+ return results, errs
+}
+
+// FanoutReportErrors writes one warning line per FanoutError to w in source
+// order. Format: "warn: : \n" where is
+// the error's first line truncated to 120 chars. No-op when errs is empty.
+//
+// Call this after FanoutRun so partial failures never get silently dropped
+// — the warning surface is the whole reason for the helper.
+func FanoutReportErrors(w io.Writer, errs []FanoutError) {
+ for _, e := range errs {
+ fmt.Fprintf(w, "warn: %s: %s\n", e.Source, shortFanoutErr(e.Err))
+ }
+}
+
+// shortFanoutErr condenses an error to a single-line reason string for
+// stderr display alongside many sources.
+func shortFanoutErr(err error) string {
+ if err == nil {
+ return ""
+ }
+ s := err.Error()
+ if i := strings.Index(s, "\n"); i >= 0 {
+ s = s[:i]
+ }
+ const max = 120
+ if len(s) > max {
+ s = s[:max] + "…"
+ }
+ return s
+}
diff --git a/library/productivity/human-goat/internal/cliutil/jwtshape.go b/library/productivity/human-goat/internal/cliutil/jwtshape.go
new file mode 100644
index 0000000000..c5df0c0f94
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/jwtshape.go
@@ -0,0 +1,96 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import "strings"
+
+// JWT shape floors — empirically chosen.
+//
+// Real-world Auth0 / Cognito / Firebase / Supabase RS256 access tokens are
+// 600–1200 chars. The smallest plausible legitimate HS256 JWT (header ~30
+// chars, minimal claims ~80 chars, signature ~40 chars) is around 150 chars
+// total with a header segment ≥ 20 chars.
+//
+// Anti-targets: Cloudflare Bot Management cookies (`__cf_bm`, ~30 chars), some
+// Mixpanel distinct IDs, segment-style A/B test identifiers — all of which
+// happen to have three dot-separated base64url chunks but contain no JWT
+// payload. Without a length floor these slip past a segment-count + charset
+// check and get saved as access tokens, with the failure mode of a confusing
+// HTTP 401 chain on every subsequent API call.
+const (
+ minJWTTotalLen = 150
+ minJWTHeaderLen = 20
+)
+
+// LooksLikeJWT reports whether s is shaped like a JWT — three base64url
+// segments separated by dots, with a length floor that filters out short
+// tracking cookies and CSRF tokens that share the segment shape.
+//
+// The function is permissive about a leading `Bearer ` prefix (it strips it
+// before measuring) so callers can pass either the raw token or the wire
+// value of an Authorization header.
+//
+// This is a shape check, not a signature verification. Callers that need to
+// know whether a token is currently valid for a specific audience should
+// decode it via the standard JWT library; this helper exists for the
+// upstream gate — "should we even attempt to save this string as a
+// credential" — that runs before the token ever reaches the API.
+func LooksLikeJWT(s string) bool {
+ s = strings.TrimSpace(s)
+ s = strings.TrimPrefix(s, "Bearer ")
+ if len(s) < minJWTTotalLen {
+ return false
+ }
+ parts := strings.Split(s, ".")
+ if len(parts) != 3 {
+ return false
+ }
+ if len(parts[0]) < minJWTHeaderLen {
+ return false
+ }
+ for _, p := range parts {
+ if p == "" {
+ return false
+ }
+ for _, r := range p {
+ isAlnum := (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
+ if !isAlnum && r != '-' && r != '_' && r != '=' {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// FindJWTInCookieJar scans a "name=value; name=value; ..." cookie jar string
+// for the first value that satisfies LooksLikeJWT, returning "" when nothing
+// matches. Useful when a CLI's auth flow extracts a browser cookie jar and
+// the underlying API actually expects a Bearer JWT (which some sites surface
+// as a cookie alongside their session cookies).
+//
+// The length floor in LooksLikeJWT does the heavy lifting here — without it,
+// jar scans on Cloudflare-fronted sites trip on `__cf_bm` and similar.
+//
+// The returned value is the bare token (any leading `Bearer ` is stripped),
+// matching LooksLikeJWT's input normalization. A caller that builds an
+// Authorization header from the result therefore prepends `Bearer ` exactly
+// once; passing the value through verbatim does not produce a double prefix
+// even when the cookie's value carries the Authorization wire form.
+func FindJWTInCookieJar(jar string) string {
+ for _, raw := range strings.Split(jar, ";") {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ continue
+ }
+ eq := strings.IndexByte(raw, '=')
+ if eq < 0 {
+ continue
+ }
+ value := strings.TrimPrefix(strings.TrimSpace(raw[eq+1:]), "Bearer ")
+ if LooksLikeJWT(value) {
+ return value
+ }
+ }
+ return ""
+}
diff --git a/library/productivity/human-goat/internal/cliutil/jwtshape_test.go b/library/productivity/human-goat/internal/cliutil/jwtshape_test.go
new file mode 100644
index 0000000000..aa19e90f3f
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/jwtshape_test.go
@@ -0,0 +1,128 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestLooksLikeJWT(t *testing.T) {
+ t.Parallel()
+
+ // realAuth0RS256 is a realistically-sized RS256 token (header ~64 chars,
+ // payload ~430 chars, signature ~344 chars). Synthetic — not a credential.
+ realAuth0RS256 := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFvNi1PYzAyQlZHTF9GbnBDeE5JMiJ9." +
+ strings.Repeat("a", 430) + "." + strings.Repeat("b", 344)
+
+ // Exact boundary fixtures. The floor is 150 chars total; these straddle it.
+ // atFloor149: h×36 + "." + p×72 + "." + s×39 = 36+1+72+1+39 = 149 — must reject.
+ // atFloor150: h×36 + "." + p×72 + "." + s×40 = 36+1+72+1+40 = 150 — must accept.
+ // A constant change (150 -> 155) or an off-by-one (< vs <=) would flip one
+ // of these and trip the test.
+ atFloor149 := strings.Repeat("h", 36) + "." + strings.Repeat("p", 72) + "." + strings.Repeat("s", 39)
+ atFloor150 := strings.Repeat("h", 36) + "." + strings.Repeat("p", 72) + "." + strings.Repeat("s", 40)
+
+ // minimalHS256 is a realistic minimum-sized JWT (~158 chars). The exact
+ // boundary cases above test the floor; this case exists so a regression
+ // that breaks normal-sized small JWTs surfaces independently.
+ minimalHS256 := strings.Repeat("h", 36) + "." + strings.Repeat("p", 80) + "." + strings.Repeat("s", 40)
+
+ // Factor75 false-positive — Cloudflare-shaped cookie value with 3
+ // base64url segments and 31 chars total. The original looksLikeJWT
+ // heuristic accepted this; the length-floored shape check rejects it.
+ cfCookieShaped := "01KRPVRYA2SNQT9BAGD6984WAG_.tt.1"
+
+ cases := []struct {
+ name string
+ in string
+ want bool
+ }{
+ {"real Auth0 RS256 token", realAuth0RS256, true},
+ {"exactly at 150-char floor", atFloor150, true},
+ {"realistic minimum HS256", minimalHS256, true},
+ {"with Bearer prefix", "Bearer " + realAuth0RS256, true},
+
+ {"exactly one char under floor (149)", atFloor149, false},
+ {"factor75 Cloudflare cookie", cfCookieShaped, false},
+ {"empty string", "", false},
+ {"whitespace only", " \t\n", false},
+ {"single segment", strings.Repeat("a", 200), false},
+ {"two segments", strings.Repeat("a", 100) + "." + strings.Repeat("b", 100), false},
+ {"four segments", "aaa.bbb.ccc.ddd", false},
+ {"empty middle segment", strings.Repeat("a", 80) + ".." + strings.Repeat("b", 80), false},
+ {"header segment too short", "aaa." + strings.Repeat("p", 80) + "." + strings.Repeat("s", 80), false},
+ {"invalid charset", strings.Repeat("a", 60) + ".pay!load." + strings.Repeat("s", 80), false},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := LooksLikeJWT(tc.in)
+ if got != tc.want {
+ t.Fatalf("LooksLikeJWT(%q) = %v, want %v", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFindJWTInCookieJar(t *testing.T) {
+ t.Parallel()
+
+ realJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFvNi1PYzAyQlZHTF9GbnBDeE5JMiJ9." +
+ strings.Repeat("a", 430) + "." + strings.Repeat("b", 344)
+
+ cases := []struct {
+ name string
+ jar string
+ want string
+ }{
+ {
+ name: "JWT alongside CF tracking cookies",
+ jar: "__cf_bm=01KRPVRYA2SNQT9BAGD6984WAG_.tt.1; auth_token=" + realJWT + "; csrf=abc123",
+ want: realJWT,
+ },
+ {
+ // A cookie whose value carries the Authorization wire form
+ // ("Bearer eyJ...") must not double-prefix downstream. The
+ // returned value should be the bare token so callers building
+ // an Authorization header always prepend "Bearer " exactly once.
+ name: "cookie value carries Bearer prefix",
+ jar: "auth=Bearer " + realJWT + "; csrf=abc",
+ want: realJWT,
+ },
+ {
+ name: "no JWT, only short shaped cookies",
+ jar: "__cf_bm=01KRPVRYA2SNQT9BAGD6984WAG_.tt.1; _ga=GA1.1.123.456",
+ want: "",
+ },
+ {
+ name: "empty jar",
+ jar: "",
+ want: "",
+ },
+ {
+ name: "single JWT cookie",
+ jar: "token=" + realJWT,
+ want: realJWT,
+ },
+ {
+ name: "malformed cookie without equals",
+ jar: "notacookie; " + realJWT,
+ want: "",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := FindJWTInCookieJar(tc.jar)
+ if got != tc.want {
+ t.Fatalf("FindJWTInCookieJar(...) returned %d-char string; want %d-char", len(got), len(tc.want))
+ }
+ })
+ }
+}
diff --git a/library/productivity/human-goat/internal/cliutil/odata_date.go b/library/productivity/human-goat/internal/cliutil/odata_date.go
new file mode 100644
index 0000000000..5c2ca79172
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/odata_date.go
@@ -0,0 +1,37 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "regexp"
+ "strconv"
+ "time"
+)
+
+// odataDateRE matches the canonical OData v3 "/Date(milliseconds)/" literal,
+// with an optional signed timezone offset that OData includes only as a display
+// hint (the millisecond count is already a UTC epoch value). Both wrapping
+// slashes are required, matching the form real OData v3 producers emit.
+var odataDateRE = regexp.MustCompile(`^/Date\((-?\d+)(?:[+-]\d{4})?\)/$`)
+
+// ParseODataDate decodes an OData v3 "/Date(ms)/" literal to a UTC time.Time.
+// It falls back to RFC3339 (normalising the result to UTC) so callers can pass
+// any datetime field without dispatching on format. Returns ok=false (and the
+// zero time) when the input matches neither form.
+//
+// Why this exists: OData v3 APIs (Exact Online, Microsoft Dynamics 365
+// Business Central, Dynamics NAV) encode dates as "/Date(1715731200000)/"
+// string literals that no standard parser accepts. Without this helper every
+// OData CLI re-implements the same regex, usually inline and inconsistently.
+func ParseODataDate(s string) (time.Time, bool) {
+ if m := odataDateRE.FindStringSubmatch(s); m != nil {
+ if ms, err := strconv.ParseInt(m[1], 10, 64); err == nil {
+ return time.UnixMilli(ms).UTC(), true
+ }
+ }
+ if t, err := time.Parse(time.RFC3339, s); err == nil {
+ return t.UTC(), true
+ }
+ return time.Time{}, false
+}
diff --git a/library/productivity/human-goat/internal/cliutil/odata_date_test.go b/library/productivity/human-goat/internal/cliutil/odata_date_test.go
new file mode 100644
index 0000000000..220c60c398
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/odata_date_test.go
@@ -0,0 +1,62 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseODataDate(t *testing.T) {
+ t.Parallel()
+
+ wantMS := time.UnixMilli(1715731200000).UTC()
+
+ t.Run("date literal to UTC", func(t *testing.T) {
+ t.Parallel()
+ got, ok := ParseODataDate("/Date(1715731200000)/")
+ if !ok {
+ t.Fatal("expected ok=true for /Date(ms)/ literal")
+ }
+ if !got.Equal(wantMS) || got.Location() != time.UTC {
+ t.Fatalf("got %v, want %v (UTC)", got, wantMS)
+ }
+ })
+
+ t.Run("date literal with timezone offset", func(t *testing.T) {
+ t.Parallel()
+ got, ok := ParseODataDate("/Date(1715731200000-0500)/")
+ if !ok || !got.Equal(wantMS) {
+ t.Fatalf("offset literal: got %v ok=%v, want %v", got, ok, wantMS)
+ }
+ })
+
+ t.Run("rfc3339 fallback", func(t *testing.T) {
+ t.Parallel()
+ want, _ := time.Parse(time.RFC3339, "2026-05-17T12:34:56Z")
+ got, ok := ParseODataDate("2026-05-17T12:34:56Z")
+ if !ok || !got.Equal(want) {
+ t.Fatalf("rfc3339: got %v ok=%v, want %v", got, ok, want)
+ }
+ })
+
+ t.Run("rfc3339 non-UTC offset normalised to UTC", func(t *testing.T) {
+ t.Parallel()
+ got, ok := ParseODataDate("2026-05-17T12:34:56+05:30")
+ if !ok {
+ t.Fatal("expected ok=true for RFC3339 with non-UTC offset")
+ }
+ if got.Location() != time.UTC {
+ t.Fatalf("expected UTC location, got %v", got.Location())
+ }
+ })
+
+ t.Run("garbage returns zero and false", func(t *testing.T) {
+ t.Parallel()
+ got, ok := ParseODataDate("not-a-date")
+ if ok || !got.IsZero() {
+ t.Fatalf("garbage: got %v ok=%v, want zero+false", got, ok)
+ }
+ })
+}
diff --git a/library/productivity/human-goat/internal/cliutil/paths.go b/library/productivity/human-goat/internal/cliutil/paths.go
new file mode 100644
index 0000000000..08eefe18a5
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/paths.go
@@ -0,0 +1,330 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+const appName = "human-goat-pp-cli"
+const envPrefix = "HUMAN_GOAT"
+
+type PathKind int
+
+const (
+ PathKindConfig PathKind = iota
+ PathKindData
+ PathKindState
+ PathKindCache
+)
+
+// pathHomeOverride holds the --home flag value. SetHomeOverride writes
+// it once at command init; the MCP server's request handlers resolve
+// paths concurrently through ResolveKindDir, so access is guarded.
+var (
+ pathHomeOverrideMu sync.RWMutex
+ pathHomeOverride string
+ pathWarnedMu sync.Mutex
+ pathWarned = map[string]struct{}{}
+)
+
+type PathResolution struct {
+ Kind PathKind
+ KindName string
+ Dir string
+ Rung string
+ Source string
+ IgnoredOverrides []PathIgnoredOverride
+}
+
+type PathIgnoredOverride struct {
+ Name string
+ Value string
+}
+
+func SetHomeOverride(path string) (func(), error) {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return setHomeOverride(""), nil
+ }
+ clean, ok := CleanPathOverride(path)
+ if !ok {
+ return nil, fmt.Errorf("invalid --home %q: path must be absolute", path)
+ }
+ if info, err := os.Stat(clean); err == nil {
+ if !info.IsDir() {
+ return nil, fmt.Errorf("--home %q: not a directory", clean)
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("checking --home %q: %w", clean, err)
+ }
+ return setHomeOverride(clean), nil
+}
+
+// setHomeOverride swaps in value under lock and returns a restore
+// function that reinstates the prior value (also under lock).
+func setHomeOverride(value string) func() {
+ pathHomeOverrideMu.Lock()
+ previous := pathHomeOverride
+ pathHomeOverride = value
+ pathHomeOverrideMu.Unlock()
+ return func() {
+ pathHomeOverrideMu.Lock()
+ pathHomeOverride = previous
+ pathHomeOverrideMu.Unlock()
+ }
+}
+
+func homeOverride() string {
+ pathHomeOverrideMu.RLock()
+ defer pathHomeOverrideMu.RUnlock()
+ return pathHomeOverride
+}
+
+func HomeOverrideActive() bool {
+ return homeOverride() != ""
+}
+
+func ConfigDir() (string, error) {
+ return KindDir(PathKindConfig)
+}
+
+func DataDir() (string, error) {
+ return KindDir(PathKindData)
+}
+
+func StateDir() (string, error) {
+ return KindDir(PathKindState)
+}
+
+func CacheDir() (string, error) {
+ return KindDir(PathKindCache)
+}
+
+func ReadFileWithLegacyFallback(primary, legacy string) ([]byte, string, error) {
+ data, err := os.ReadFile(primary)
+ if err == nil {
+ return data, primary, nil
+ }
+ if !errors.Is(err, os.ErrNotExist) || legacy == "" || legacy == primary {
+ return nil, primary, err
+ }
+ data, legacyErr := os.ReadFile(legacy)
+ if legacyErr != nil {
+ return nil, legacy, legacyErr
+ }
+ return data, legacy, nil
+}
+
+func AtomicWritePrivateFile(path string, data []byte, fileMode, dirMode os.FileMode) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, dirMode); err != nil {
+ return fmt.Errorf("creating private file dir: %w", err)
+ }
+ tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
+ if err != nil {
+ return fmt.Errorf("creating temporary private file: %w", err)
+ }
+ tmpPath := tmp.Name()
+ if err := tmp.Chmod(fileMode); err != nil {
+ tmp.Close()
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("securing temporary private file: %w", err)
+ }
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("writing temporary private file: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("closing temporary private file: %w", err)
+ }
+ if err := os.Rename(tmpPath, path); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("publishing private file: %w", err)
+ }
+ return nil
+}
+
+func KindDir(kind PathKind) (string, error) {
+ resolution, err := ResolveKindDir(kind)
+ if err != nil {
+ return "", err
+ }
+ return resolution.Dir, nil
+}
+
+func ResolveKindDir(kind PathKind) (PathResolution, error) {
+ var ignored []PathIgnoredOverride
+ if override, ok, skipped := envDir(kindEnvVar(kind)); ok {
+ return pathResolution(kind, override, "per-kind-env", kindEnvVar(kind), ignored), nil
+ } else if skipped != nil {
+ ignored = append(ignored, *skipped)
+ }
+ if override := homeOverride(); override != "" {
+ return pathResolution(kind, filepath.Join(override, kindName(kind)), "--home", "--home", ignored), nil
+ }
+ if home, ok, skipped := envDir(envPrefix + "_HOME"); ok {
+ return pathResolution(kind, filepath.Join(home, kindName(kind)), "home-env", envPrefix+"_HOME", ignored), nil
+ } else if skipped != nil {
+ ignored = append(ignored, *skipped)
+ }
+ if xdg, ok, skipped := envDir(xdgEnvVar(kind)); ok {
+ return pathResolution(kind, filepath.Join(xdg, appName), "xdg-env", xdgEnvVar(kind), ignored), nil
+ } else if skipped != nil {
+ ignored = append(ignored, *skipped)
+ }
+ base, err := defaultBase(kind)
+ if err != nil {
+ return PathResolution{}, err
+ }
+ return pathResolution(kind, filepath.Join(base, appName), "platform-default", "platform-default", ignored), nil
+}
+
+func AllPathResolutions() ([]PathResolution, error) {
+ kinds := []PathKind{PathKindConfig, PathKindData, PathKindState, PathKindCache}
+ resolutions := make([]PathResolution, 0, len(kinds))
+ for _, kind := range kinds {
+ resolution, err := ResolveKindDir(kind)
+ if err != nil {
+ return nil, err
+ }
+ resolutions = append(resolutions, resolution)
+ }
+ return resolutions, nil
+}
+
+func pathResolution(kind PathKind, dir, rung, source string, ignored []PathIgnoredOverride) PathResolution {
+ return PathResolution{
+ Kind: kind,
+ KindName: kindName(kind),
+ Dir: dir,
+ Rung: rung,
+ Source: source,
+ IgnoredOverrides: ignored,
+ }
+}
+
+func kindName(kind PathKind) string {
+ switch kind {
+ case PathKindConfig:
+ return "config"
+ case PathKindData:
+ return "data"
+ case PathKindState:
+ return "state"
+ case PathKindCache:
+ return "cache"
+ default:
+ return "unknown"
+ }
+}
+
+func pathKindEnvSuffix(kind PathKind) string {
+ switch kind {
+ case PathKindConfig:
+ return "CONFIG_DIR"
+ case PathKindData:
+ return "DATA_DIR"
+ case PathKindState:
+ return "STATE_DIR"
+ case PathKindCache:
+ return "CACHE_DIR"
+ default:
+ return ""
+ }
+}
+
+func kindEnvVar(kind PathKind) string {
+ suffix := pathKindEnvSuffix(kind)
+ if suffix == "" {
+ return ""
+ }
+ return envPrefix + "_" + suffix
+}
+
+func xdgEnvVar(kind PathKind) string {
+ suffix := strings.TrimSuffix(pathKindEnvSuffix(kind), "_DIR")
+ if suffix == "" {
+ return ""
+ }
+ return "XDG_" + suffix + "_HOME"
+}
+
+func envDir(name string) (string, bool, *PathIgnoredOverride) {
+ if name == "" {
+ return "", false, nil
+ }
+ raw := strings.TrimSpace(os.Getenv(name))
+ if raw == "" {
+ return "", false, nil
+ }
+ clean, ok := CleanPathOverride(raw)
+ if !ok {
+ warnSkippedPathOverride(name, raw)
+ return "", false, &PathIgnoredOverride{Name: name, Value: raw}
+ }
+ return clean, true, nil
+}
+
+// CleanPathOverride trims, tilde-expands, and validates raw as an
+// absolute path, returning the cleaned path and whether it is usable.
+func CleanPathOverride(raw string) (string, bool) {
+ expanded := expandTilde(strings.TrimSpace(raw))
+ if expanded == "" || !filepath.IsAbs(expanded) {
+ return "", false
+ }
+ return filepath.Clean(expanded), true
+}
+
+func expandTilde(path string) string {
+ if path == "~" {
+ if home, err := os.UserHomeDir(); err == nil {
+ return home
+ }
+ return path
+ }
+ if strings.HasPrefix(path, "~/") {
+ if home, err := os.UserHomeDir(); err == nil {
+ return filepath.Join(home, strings.TrimPrefix(path, "~/"))
+ }
+ }
+ return path
+}
+
+func warnSkippedPathOverride(name, raw string) {
+ key := name + "\x00" + raw
+ pathWarnedMu.Lock()
+ defer pathWarnedMu.Unlock()
+ if _, ok := pathWarned[key]; ok {
+ return
+ }
+ pathWarned[key] = struct{}{}
+ fmt.Fprintf(os.Stderr, "warning: ignoring %s=%q: path must be absolute\n", name, raw)
+}
+
+func defaultBase(kind PathKind) (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolve user home dir: %w", err)
+ }
+ switch kind {
+ case PathKindConfig:
+ return filepath.Join(home, ".config"), nil
+ case PathKindData:
+ return filepath.Join(home, ".local", "share"), nil
+ case PathKindState:
+ return filepath.Join(home, ".local", "state"), nil
+ case PathKindCache:
+ return filepath.Join(home, ".cache"), nil
+ default:
+ return "", fmt.Errorf("unknown path kind %d", kind)
+ }
+}
diff --git a/library/productivity/human-goat/internal/cliutil/paths_test.go b/library/productivity/human-goat/internal/cliutil/paths_test.go
new file mode 100644
index 0000000000..df669c05dc
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/paths_test.go
@@ -0,0 +1,228 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func resetPathEnv(t *testing.T) string {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ for _, name := range []string{
+ envPrefix + "_CONFIG_DIR",
+ envPrefix + "_DATA_DIR",
+ envPrefix + "_STATE_DIR",
+ envPrefix + "_CACHE_DIR",
+ envPrefix + "_HOME",
+ "XDG_CONFIG_HOME",
+ "XDG_DATA_HOME",
+ "XDG_STATE_HOME",
+ "XDG_CACHE_HOME",
+ } {
+ t.Setenv(name, "")
+ }
+ restore, err := SetHomeOverride("")
+ if err != nil {
+ t.Fatalf("reset home override: %v", err)
+ }
+ t.Cleanup(restore)
+ return home
+}
+
+func TestKindDirDefaultsMatchLegacyLayout(t *testing.T) {
+ home := resetPathEnv(t)
+
+ tests := []struct {
+ kind PathKind
+ want string
+ }{
+ {PathKindConfig, filepath.Join(home, ".config", appName)},
+ {PathKindData, filepath.Join(home, ".local", "share", appName)},
+ {PathKindState, filepath.Join(home, ".local", "state", appName)},
+ {PathKindCache, filepath.Join(home, ".cache", appName)},
+ }
+ for _, tt := range tests {
+ got, err := KindDir(tt.kind)
+ if err != nil {
+ t.Fatalf("KindDir(%s) error = %v", kindName(tt.kind), err)
+ }
+ if got != tt.want {
+ t.Fatalf("KindDir(%s) = %q, want %q", kindName(tt.kind), got, tt.want)
+ }
+ }
+}
+
+func TestKindDirHomeEnvUsesFlatKindLayout(t *testing.T) {
+ resetPathEnv(t)
+ root := filepath.Join(t.TempDir(), "persist")
+ t.Setenv(envPrefix+"_HOME", root)
+
+ tests := map[PathKind]string{
+ PathKindConfig: filepath.Join(root, "config"),
+ PathKindData: filepath.Join(root, "data"),
+ PathKindState: filepath.Join(root, "state"),
+ PathKindCache: filepath.Join(root, "cache"),
+ }
+ for kind, want := range tests {
+ got, err := KindDir(kind)
+ if err != nil {
+ t.Fatalf("KindDir(%s) error = %v", kindName(kind), err)
+ }
+ if got != want {
+ t.Fatalf("KindDir(%s) = %q, want %q", kindName(kind), got, want)
+ }
+ }
+}
+
+func TestKindDirPerKindEnvBeatsHomeEnv(t *testing.T) {
+ resetPathEnv(t)
+ root := filepath.Join(t.TempDir(), "root")
+ data := filepath.Join(t.TempDir(), "secure-data")
+ t.Setenv(envPrefix+"_HOME", root)
+ t.Setenv(envPrefix+"_DATA_DIR", data)
+
+ got, err := DataDir()
+ if err != nil {
+ t.Fatalf("DataDir() error = %v", err)
+ }
+ if got != data {
+ t.Fatalf("DataDir() = %q, want literal per-kind dir %q", got, data)
+ }
+ configDir, err := ConfigDir()
+ if err != nil {
+ t.Fatalf("ConfigDir() error = %v", err)
+ }
+ if want := filepath.Join(root, "config"); configDir != want {
+ t.Fatalf("ConfigDir() = %q, want %q", configDir, want)
+ }
+}
+
+func TestKindDirXDGAddsAppName(t *testing.T) {
+ resetPathEnv(t)
+ xdg := filepath.Join(t.TempDir(), "xdg-data")
+ t.Setenv("XDG_DATA_HOME", xdg)
+
+ got, err := DataDir()
+ if err != nil {
+ t.Fatalf("DataDir() error = %v", err)
+ }
+ if want := filepath.Join(xdg, appName); got != want {
+ t.Fatalf("DataDir() = %q, want %q", got, want)
+ }
+}
+
+func TestKindDirDataPrecedencePairs(t *testing.T) {
+ home := resetPathEnv(t)
+ perKind := filepath.Join(t.TempDir(), "per-kind")
+ flagHome := filepath.Join(t.TempDir(), "flag-home")
+ envHome := filepath.Join(t.TempDir(), "env-home")
+ xdg := filepath.Join(t.TempDir(), "xdg")
+ t.Setenv(envPrefix+"_DATA_DIR", perKind)
+ t.Setenv(envPrefix+"_HOME", envHome)
+ t.Setenv("XDG_DATA_HOME", xdg)
+ restore, err := SetHomeOverride(flagHome)
+ if err != nil {
+ t.Fatalf("SetHomeOverride() error = %v", err)
+ }
+ defer restore()
+
+ assertDataDir(t, perKind)
+ t.Setenv(envPrefix+"_DATA_DIR", "")
+ assertDataDir(t, filepath.Join(flagHome, "data"))
+ restore()
+ assertDataDir(t, filepath.Join(envHome, "data"))
+ t.Setenv(envPrefix+"_HOME", "")
+ assertDataDir(t, filepath.Join(xdg, appName))
+ t.Setenv("XDG_DATA_HOME", "")
+ assertDataDir(t, filepath.Join(home, ".local", "share", appName))
+}
+
+func TestKindDirRelativeOverridesWarnAndFallThrough(t *testing.T) {
+ home := resetPathEnv(t)
+ t.Setenv(envPrefix+"_HOME", "relative/home")
+ t.Setenv("XDG_DATA_HOME", "relative/xdg")
+
+ stderr := captureStderr(t, func() {
+ got, err := DataDir()
+ if err != nil {
+ t.Fatalf("DataDir() error = %v", err)
+ }
+ if want := filepath.Join(home, ".local", "share", appName); got != want {
+ t.Fatalf("DataDir() = %q, want %q", got, want)
+ }
+ })
+ for _, want := range []string{envPrefix + "_HOME", "relative/home", "XDG_DATA_HOME", "relative/xdg"} {
+ if !strings.Contains(stderr, want) {
+ t.Fatalf("stderr %q does not mention %q", stderr, want)
+ }
+ }
+}
+
+func TestSetHomeOverrideRejectsRelative(t *testing.T) {
+ resetPathEnv(t)
+ if _, err := SetHomeOverride("../elsewhere"); err == nil || !strings.Contains(err.Error(), "--home") {
+ t.Fatalf("SetHomeOverride(relative) error = %v, want --home absolute-path error", err)
+ }
+}
+
+func TestSetHomeOverrideRejectsRegularFile(t *testing.T) {
+ resetPathEnv(t)
+ path := filepath.Join(t.TempDir(), "not-a-dir")
+ if err := os.WriteFile(path, []byte("file"), 0o600); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+ if _, err := SetHomeOverride(path); err == nil || !strings.Contains(err.Error(), "not a directory") {
+ t.Fatalf("SetHomeOverride(file) error = %v, want not-a-directory error", err)
+ }
+}
+
+func TestSetHomeOverrideExpandsTildeAndCleans(t *testing.T) {
+ home := resetPathEnv(t)
+ restore, err := SetHomeOverride("~/state/../root")
+ if err != nil {
+ t.Fatalf("SetHomeOverride() error = %v", err)
+ }
+ defer restore()
+ got, err := CacheDir()
+ if err != nil {
+ t.Fatalf("CacheDir() error = %v", err)
+ }
+ if want := filepath.Join(home, "root", "cache"); got != want {
+ t.Fatalf("CacheDir() = %q, want %q", got, want)
+ }
+}
+
+func assertDataDir(t *testing.T, want string) {
+ t.Helper()
+ got, err := DataDir()
+ if err != nil {
+ t.Fatalf("DataDir() error = %v", err)
+ }
+ if got != want {
+ t.Fatalf("DataDir() = %q, want %q", got, want)
+ }
+}
+
+func captureStderr(t *testing.T, fn func()) string {
+ t.Helper()
+ old := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("pipe: %v", err)
+ }
+ os.Stderr = w
+ fn()
+ _ = w.Close()
+ os.Stderr = old
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
diff --git a/library/productivity/human-goat/internal/cliutil/probe.go b/library/productivity/human-goat/internal/cliutil/probe.go
new file mode 100644
index 0000000000..de5290437b
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/probe.go
@@ -0,0 +1,104 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// defaultProbeTimeout caps the request when the caller passes a nil
+// client and didn't set a context deadline. Without this cap, a probe
+// against a non-responsive host could hang indefinitely (the global
+// http.DefaultClient has no Timeout). Callers who pass their own
+// *http.Client are expected to set Timeout there; this value only
+// applies to the nil-client fallback.
+const defaultProbeTimeout = 10 * time.Second
+
+// ReachabilityStatus is one of the strings returned by ProbeReachable.
+// Callers (typically a doctor command listing per-source health) match
+// on these constants when deciding whether to render OK/WARN/FAIL.
+const (
+ // ReachabilityReachable means the host responded with a 2xx, a 206
+ // (partial — Range honored), or a 416 (Range not honored, but the
+ // host did respond with headers). All three are evidence that the
+ // host is alive and responding to GET.
+ ReachabilityReachable = "reachable"
+ // ReachabilityBlocked means the host responded with a 4xx (other
+ // than 416) or 5xx. The host is up but is refusing this request —
+ // usually a CDN bot screen, a paywall, or a server error.
+ ReachabilityBlocked = "blocked"
+ // ReachabilityUnreachable means the request errored at the network
+ // layer — DNS failure, connection refused, TLS shutdown, timeout.
+ ReachabilityUnreachable = "unreachable"
+)
+
+// ProbeReachable does a lightweight reachability probe against url
+// using client and returns a (status, code, err) triple. The probe
+// uses GET with a `Range: bytes=0-1023` header so it never pulls more
+// than ~1 KB of body, regardless of how the host responds; the body
+// is read and discarded so the connection can be released.
+//
+// Why not HEAD: many recipe-site CDNs (BBC, RecipeTin Eats, AllRecipes,
+// Serious Eats, The Kitchn) terminate HEAD requests with a TLS
+// shutdown / EOF even though they serve GET cleanly. A HEAD-based
+// probe lies — reporting "unreachable EOF" for hosts that work fine
+// for the real fetch path. recipe-goat hit this in retro #301
+// finding F4: doctor reported six sites unreachable that the goat
+// ranker was successfully scraping.
+//
+// Use this from any doctor or health-check command that does
+// per-source reachability fan-out, with the same client that the real
+// fetch path uses (typically a Surf-Chrome client built via
+// surf.NewClient().Builder().Impersonate().Chrome().Build()). Probe
+// drift between the doctor probe and the fetch path is the bug class
+// this helper exists to prevent.
+//
+// Returned values:
+// - status is one of ReachabilityReachable, ReachabilityBlocked, or
+// ReachabilityUnreachable.
+// - code is the HTTP status code, or 0 when the request errored at
+// the network layer.
+// - err is non-nil only for network-layer failures. A 4xx or 5xx
+// response is reported via status/code with err == nil.
+func ProbeReachable(ctx context.Context, client *http.Client, url string) (status string, code int, err error) {
+ if client == nil {
+ // Build a copy of DefaultClient with a bounded timeout — the
+ // global DefaultClient has none, so a nil-client probe against
+ // a slow host would hang. Callers passing their own client are
+ // expected to set Timeout themselves.
+ client = &http.Client{Timeout: defaultProbeTimeout}
+ }
+ req, reqErr := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if reqErr != nil {
+ return ReachabilityUnreachable, 0, fmt.Errorf("building request: %w", reqErr)
+ }
+ // Range: bytes=0-1023 keeps body bounded for hosts that honor it.
+ // Hosts that don't support Range respond with 200 + full body or
+ // 416 — both are caught below as "reachable", and the limited
+ // io.Copy below ensures we never pull more than 1 KiB anyway.
+ req.Header.Set("Range", "bytes=0-1023")
+ resp, doErr := client.Do(req)
+ if doErr != nil {
+ return ReachabilityUnreachable, 0, doErr
+ }
+ defer resp.Body.Close()
+ // Drain up to 2 KiB so the connection can be reused. We read past
+ // the 1024-byte Range hint to cover hosts that ignored it.
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048))
+ switch {
+ case resp.StatusCode >= 200 && resp.StatusCode < 300:
+ return ReachabilityReachable, resp.StatusCode, nil
+ case resp.StatusCode == http.StatusRequestedRangeNotSatisfiable:
+ // 416 means the host doesn't support Range. We still got
+ // headers back, so the host is up and responding to GET —
+ // classify as reachable.
+ return ReachabilityReachable, resp.StatusCode, nil
+ default:
+ return ReachabilityBlocked, resp.StatusCode, nil
+ }
+}
diff --git a/library/productivity/human-goat/internal/cliutil/ratelimit.go b/library/productivity/human-goat/internal/cliutil/ratelimit.go
new file mode 100644
index 0000000000..641058980b
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/ratelimit.go
@@ -0,0 +1,207 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "fmt"
+ "math"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// AdaptiveLimiter paces outbound requests with adaptive ceiling discovery.
+// Starts at a floor rate, ramps up after consecutive successes, halves on 429
+// and records a ceiling. Per-session only — not persisted. Methods are safe
+// to call on a nil receiver.
+type AdaptiveLimiter struct {
+ mu sync.Mutex
+ rate float64
+ floor float64
+ ceiling float64
+ successes int
+ rampAfter int
+ lastRequest time.Time // zero-value: first Wait() returns immediately
+}
+
+// NewAdaptiveLimiter returns a limiter starting at ratePerSec, or nil when
+// rate-limiting should be disabled. Methods on the nil limiter no-op.
+func NewAdaptiveLimiter(ratePerSec float64) *AdaptiveLimiter {
+ if ratePerSec <= 0 {
+ return nil
+ }
+ floor := 0.5
+ if ratePerSec < floor {
+ floor = ratePerSec
+ }
+ return &AdaptiveLimiter{
+ rate: ratePerSec,
+ floor: floor,
+ rampAfter: 10,
+ }
+}
+
+func (l *AdaptiveLimiter) Wait() {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ delay := time.Duration(float64(time.Second) / l.rate)
+ elapsed := time.Since(l.lastRequest)
+ var sleep time.Duration
+ if elapsed < delay {
+ sleep = delay - elapsed
+ }
+ l.lastRequest = time.Now().Add(sleep)
+ l.mu.Unlock()
+ if sleep > 0 {
+ time.Sleep(sleep)
+ }
+}
+
+func (l *AdaptiveLimiter) OnSuccess() {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.successes++
+ if l.successes >= l.rampAfter {
+ newRate := l.rate * 1.25
+ if l.ceiling > 0 && newRate > l.ceiling*0.9 {
+ newRate = l.ceiling * 0.9
+ }
+ if newRate < l.floor {
+ newRate = l.floor
+ }
+ l.rate = newRate
+ l.successes = 0
+ }
+}
+
+func (l *AdaptiveLimiter) OnRateLimit() {
+ if l == nil {
+ return
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.ceiling = l.rate
+ l.rate = l.rate / 2
+ if l.rate < l.floor {
+ l.rate = l.floor
+ }
+ l.successes = 0
+}
+
+func (l *AdaptiveLimiter) Rate() float64 {
+ if l == nil {
+ return 0
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.rate
+}
+
+// RateLimitError signals an upstream returned 429 after retries were
+// exhausted. Callers must surface this as a hard error rather than empty
+// results — empty-on-throttle is indistinguishable from "no data exists"
+// and silently corrupts downstream queries.
+type RateLimitError struct {
+ URL string
+ RetryAfter time.Duration
+ Body string
+}
+
+func (e *RateLimitError) Error() string {
+ msg := fmt.Sprintf("rate limited: HTTP 429 for %s", e.URL)
+ if e.RetryAfter > 0 {
+ msg += fmt.Sprintf("; retry after %s", e.RetryAfter)
+ }
+ if body := strings.TrimSpace(e.Body); body != "" {
+ msg += ": " + body
+ }
+ return msg
+}
+
+// MaxRetryWait caps the wait derived from a Retry-After header so a buggy
+// or hostile upstream cannot pin a CLI for hours.
+const MaxRetryWait = 60 * time.Second
+
+const (
+ defaultRetryWait = 5 * time.Second
+ unixEpochSecondsThreshold = 1_000_000_000
+ unixEpochMillisecondsThreshold = 1_000_000_000_000
+)
+
+// RetryAfter parses an HTTP Retry-After header (RFC 7231: delta-seconds or
+// HTTP-date), plus common Unix epoch seconds/milliseconds variants emitted by
+// some APIs. Waits are capped at MaxRetryWait. Returns 5s when missing or
+// unparseable.
+func RetryAfter(resp *http.Response) time.Duration {
+ if resp == nil {
+ return defaultRetryWait
+ }
+ header := strings.TrimSpace(resp.Header.Get("Retry-After"))
+ if header == "" {
+ return defaultRetryWait
+ }
+ if value, err := strconv.ParseInt(header, 10, 64); err == nil {
+ return retryAfterFromNumber(value)
+ }
+ if t, err := http.ParseTime(header); err == nil {
+ wait := time.Until(t)
+ if wait > MaxRetryWait {
+ return MaxRetryWait
+ }
+ if wait > 0 {
+ return wait
+ }
+ }
+ return defaultRetryWait
+}
+
+func retryAfterFromNumber(value int64) time.Duration {
+ if value <= 0 {
+ return defaultRetryWait
+ }
+ if value > int64(MaxRetryWait/time.Second) {
+ if wait := retryAfterEpochWait(value); wait > 0 {
+ if wait > MaxRetryWait {
+ return MaxRetryWait
+ }
+ return wait
+ }
+ return MaxRetryWait
+ }
+ return time.Duration(value) * time.Second
+}
+
+func retryAfterEpochWait(value int64) time.Duration {
+ switch {
+ case value >= unixEpochMillisecondsThreshold:
+ return time.Until(time.UnixMilli(value))
+ case value >= unixEpochSecondsThreshold:
+ return time.Until(time.Unix(value, 0))
+ default:
+ return 0
+ }
+}
+
+// MaxBackoff caps Backoff so tests stay bounded. Callers needing jitter
+// add their own; the bare exponential keeps the contract deterministic.
+const MaxBackoff = 30 * time.Second
+
+// Backoff returns 2^attempt seconds capped at MaxBackoff.
+func Backoff(attempt int) time.Duration {
+ if attempt < 0 {
+ attempt = 0
+ }
+ wait := time.Duration(math.Pow(2, float64(attempt))) * time.Second
+ if wait > MaxBackoff {
+ return MaxBackoff
+ }
+ return wait
+}
diff --git a/library/productivity/human-goat/internal/cliutil/text.go b/library/productivity/human-goat/internal/cliutil/text.go
new file mode 100644
index 0000000000..0d4439e33b
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/text.go
@@ -0,0 +1,83 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "html"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// CleanText normalizes scraped text by trimming whitespace and decoding
+// HTML entities. Always use this when extracting strings from HTML or
+// schema.org JSON-LD. Skipping this step is how recipe-goat's
+// "The Food Lab's" bug shipped: schema.org strings passed through
+// unescaped because the JSON-LD parser didn't normalize.
+//
+// Single unescape pass: "&" -> "&" (matches html.UnescapeString
+// stdlib behavior). If you need multiple passes you almost always have a
+// deeper escaping problem upstream — fix there, not here.
+func CleanText(s string) string {
+ return html.UnescapeString(strings.TrimSpace(s))
+}
+
+// ParseStoredTime parses timestamps read back from SQLite-backed generated
+// stores. modernc.org/sqlite can serialize time.Time using Go's native
+// time.String format, while hand-written sync code often stores RFC3339.
+// Use this helper instead of a single time.Parse(time.RFC3339, value) call
+// when scanning timestamp columns from the store.
+func ParseStoredTime(s string) time.Time {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return time.Time{}
+ }
+ for _, layout := range []string{
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02 15:04:05.999999999 -0700 MST",
+ "2006-01-02 15:04:05.999999 -0700 MST",
+ "2006-01-02 15:04:05.999 -0700 MST",
+ "2006-01-02 15:04:05 -0700 MST",
+ "2006-01-02 15:04:05.999999999 -0700",
+ "2006-01-02 15:04:05 -0700",
+ } {
+ if t, err := time.Parse(layout, s); err == nil {
+ return t
+ }
+ }
+ return time.Time{}
+}
+
+// LooksLikeAuthError checks if an error message body contains auth-related keywords.
+func LooksLikeAuthError(msg string) bool {
+ lower := strings.ToLower(msg)
+ patterns := []string{
+ `\bkey\b`,
+ `\btoken\b`,
+ `\bunauthorized\b`,
+ `\bapi_key\b`,
+ `missing.{0,20}key`,
+ `required.{0,20}key`,
+ `\bforbidden\b`,
+ `\bauthenticat`,
+ `\bcredential`,
+ }
+ for _, p := range patterns {
+ if matched, _ := regexp.MatchString(p, lower); matched {
+ return true
+ }
+ }
+ return false
+}
+
+// SanitizeErrorBody truncates and strips credential-shaped strings from error output.
+func SanitizeErrorBody(msg string) string {
+ if len(msg) > 200 {
+ msg = msg[:200] + "..."
+ }
+ credPatterns := regexp.MustCompile(`(?i)(sk-[a-zA-Z0-9]{8,}|sk_live_[a-zA-Z0-9]+|Bearer\s+[a-zA-Z0-9._\-]+|(?:key|token)=[a-zA-Z0-9._\-]+)`)
+ msg = credPatterns.ReplaceAllString(msg, "[REDACTED]")
+ return msg
+}
diff --git a/library/productivity/human-goat/internal/cliutil/verifyenv.go b/library/productivity/human-goat/internal/cliutil/verifyenv.go
new file mode 100644
index 0000000000..16fbec22fd
--- /dev/null
+++ b/library/productivity/human-goat/internal/cliutil/verifyenv.go
@@ -0,0 +1,93 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import "os"
+
+// VerifyEnvVar is the env var the printing-press verifier sets in every
+// mock-mode subprocess. Generated commands that perform visible side
+// effects (open browser tabs, send notifications, dial out to OS
+// handlers) MUST short-circuit when this env var is "1" to avoid
+// spamming the user's environment during verify runs.
+//
+// The transport layer in internal/client also gates mutating HTTP verbs
+// (DELETE/POST/PUT/PATCH) on this var: under verify mode such requests
+// short-circuit with a synthetic envelope and never dial. The verifier
+// itself opts back in to the real wire path via VerifyLiveHTTPEnvVar
+// so its httptest mock-server flow keeps exercising the real client.
+const VerifyEnvVar = "PRINTING_PRESS_VERIFY"
+
+// VerifyLiveHTTPEnvVar opts a verify-mode subprocess back in to the
+// real HTTP wire path for mutating verbs. It is intentionally
+// asymmetric with VerifyEnvVar: setting LIVE_HTTP=1 alone (with VERIFY
+// unset) has no behavioral effect, because the gate only consults this
+// var when IsVerifyEnv() is also true. The verify pipeline and
+// narrative full-example runner both set BOTH vars in their mock-mode
+// subprocesses (so the httptest server keeps exercising the real wire
+// path); agents and ad-hoc operators leave LIVE_HTTP unset so mutating
+// requests no-op. Live verifiers (live_dogfood, workflow_verify) strip
+// both vars from subprocess env entirely so they cannot inherit a
+// verify-mode short-circuit from the operator's shell.
+const VerifyLiveHTTPEnvVar = "PRINTING_PRESS_VERIFY_LIVE_HTTP"
+
+// DogfoodEnvVar is the env var the printing-press live-dogfood runner
+// sets in every subprocess. Distinct from VerifyEnvVar because dogfood
+// is a real-network matrix: commands may still perform actual API
+// calls, just curtailed (paginate-once, bounded crawl, etc.) so the
+// runner's flat 30s per-command timeout doesn't trip.
+const DogfoodEnvVar = "PRINTING_PRESS_DOGFOOD"
+
+// IsVerifyEnv reports whether the current process is running under the
+// printing-press verifier in mock mode. Generated commands with side
+// effects pair this check with print-by-default + explicit opt-in
+// (--launch, --send, --play) so a verify pass on a fresh CLI does not
+// pop browser tabs or fire off real notifications.
+//
+// Defense-in-depth: even if the verifier's heuristic side-effect
+// classifier misses a command, this env-var short-circuit catches it.
+//
+// if cliutil.IsVerifyEnv() {
+// fmt.Fprintln(cmd.OutOrStdout(), "would launch:", url)
+// return nil
+// }
+func IsVerifyEnv() bool {
+ return os.Getenv(VerifyEnvVar) == "1"
+}
+
+// IsVerifyLiveHTTPEnv reports whether the current process has opted
+// back in to the real HTTP wire path while running under the verifier.
+// Only meaningful when IsVerifyEnv() is also true; on its own this
+// returns true does NOT enable any sandbox behavior — see
+// VerifyLiveHTTPEnvVar's docstring for the asymmetric semantics.
+//
+// The generated client uses this gate as:
+//
+// if !readOnlyIntent && isMutatingVerb(method) && cliutil.IsVerifyEnv() && !cliutil.IsVerifyLiveHTTPEnv() {
+// // synthetic envelope, no network call
+// }
+//
+// readOnlyIntent is set by Client.doRead() callers (the PostQuery*
+// family used by codegen-marked `mcp:read-only` operations on mutating
+// verbs — GraphQL queries, JSON-RPC reads, POST-based search).
+func IsVerifyLiveHTTPEnv() bool {
+ return os.Getenv(VerifyLiveHTTPEnvVar) == "1"
+}
+
+// IsDogfoodEnv reports whether the current process is running under
+// the printing-press live-dogfood matrix. Long-running commands (full
+// sync loops, content crawlers, bulk archive walks) should use this
+// to curtail work so the flat 30s per-command timeout doesn't kill an
+// otherwise healthy happy_path test. Typical pattern: paginate once,
+// fetch a bounded sample, or honor a smaller --limit default.
+//
+// if cliutil.IsDogfoodEnv() {
+// return crawl(ctx, opts.WithMaxPages(1))
+// }
+//
+// Unlike IsVerifyEnv this does NOT mean "don't hit the network" —
+// dogfood is a real-API matrix. Use this only to bound work, never to
+// substitute mock data for real calls.
+func IsDogfoodEnv() bool {
+ return os.Getenv(DogfoodEnvVar) == "1"
+}
diff --git a/library/productivity/human-goat/internal/config/config.go b/library/productivity/human-goat/internal/config/config.go
new file mode 100644
index 0000000000..416af7acc3
--- /dev/null
+++ b/library/productivity/human-goat/internal/config/config.go
@@ -0,0 +1,495 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/pelletier/go-toml/v2"
+)
+
+type Config struct {
+ BaseURL string `toml:"base_url"`
+ AuthHeaderVal string `toml:"auth_header"`
+ Headers map[string]string `toml:"headers,omitempty"`
+ AuthSource string `toml:"-"`
+ CredentialSource string `toml:"-"`
+ AgentcookieManaged bool `toml:"-"`
+ // configOwner records which on-disk file parseConfigData populated this
+ // config from ("config-kind path" or "legacy config path") so the
+ // credential-source fallback below reports where config-stored
+ // credentials actually live. Unexported: never persisted.
+ configOwner string
+ // legacySourcePath records the legacy config path when Load fell
+ // back to it. Used by save() to scrub credential fields from the
+ // old location after relocation. Unexported: never persisted.
+ legacySourcePath string
+ AccessToken string `toml:"access_token"`
+ RefreshToken string `toml:"refresh_token"`
+ TokenExpiry time.Time `toml:"token_expiry"`
+ ClientID string `toml:"client_id"`
+ ClientSecret string `toml:"client_secret"`
+ Path string `toml:"-"`
+ envOverrides map[string]bool `toml:"-"`
+ fileConfig *Config `toml:"-"`
+}
+
+func Load(configPath string) (*Config, error) {
+ cfg := &Config{
+ BaseURL: "https://www.taskrabbit.com",
+ }
+
+ // Resolve config path
+ path, explicitConfigFile, err := resolveConfigPath(configPath)
+ if err != nil {
+ return nil, err
+ }
+ cfg.Path = path
+
+ if explicitConfigFile {
+ if err := readConfigFile(path, cfg, "config-kind path"); err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ } else {
+ legacyPath, err := LegacyConfigPath()
+ if err != nil {
+ return nil, err
+ }
+ data, sourcePath, err := cliutil.ReadFileWithLegacyFallback(path, legacyPath)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ return nil, err
+ }
+ } else {
+ owner := "config-kind path"
+ if sourcePath == legacyPath {
+ owner = "legacy config path"
+ }
+ parsed := *cfg
+ if err := parseConfigData(data, &parsed, sourcePath, owner); err != nil {
+ if sourcePath == legacyPath {
+ fmt.Fprintf(os.Stderr, "warning: legacy config parse skipped for %s: %v\n", sourcePath, err)
+ } else {
+ return nil, err
+ }
+ } else {
+ *cfg = parsed
+ if sourcePath == legacyPath {
+ cfg.legacySourcePath = legacyPath
+ }
+ }
+ }
+ }
+ cfg.Path = path
+ if cfg.AgentcookieManagedByExternalStore() {
+ cfg.markAgentcookieManaged()
+ } else {
+ creds, ok, err := cliutil.LoadCredentials()
+ if err != nil {
+ return nil, err
+ }
+ if ok && creds.HasValues() {
+ cfg.clearCredentialFields()
+ cfg.applyCredentials(creds)
+ if cfg.hasCredentialFields() {
+ cfg.AuthSource = "config"
+ cfg.CredentialSource = "credentials file"
+ }
+ }
+ }
+
+ cfg.snapshotFileConfig()
+
+ // Env var overrides
+ // Label config-file-derived credentials so doctor can distinguish
+ // "credentials persisted on disk" from "no credentials at all" — without
+ // this, users who saved via set-token without an env var see a blank
+ // auth_source and can't tell whether their config is being picked up.
+ // The label is the literal "config" rather than "config:"; the
+ // config file path is exposed separately as report["config_path"], and
+ // embedding it in auth_source leaks the user's home directory through
+ // doctor's JSON envelope.
+ if cfg.AuthSource == "" && cfg.hasCredentialFields() {
+ cfg.AuthSource = "config"
+ }
+ if cfg.CredentialSource == "" && cfg.AuthSource == "config" {
+ // Label config-stored credentials with the file they were parsed
+ // from: the resolved config-kind path (covers --home and per-kind
+ // env relocation as well as explicit --config files) or the legacy
+ // config path when the read fell back to the pre-paths layout.
+ cfg.CredentialSource = cfg.configOwner
+ if cfg.CredentialSource == "" {
+ cfg.CredentialSource = "legacy config path"
+ }
+ }
+
+ // Base URL override (used by printing-press verify to point at mock/test servers)
+ if v := os.Getenv("HUMAN_GOAT_BASE_URL"); v != "" {
+ cfg.BaseURL = v
+ }
+ return cfg, nil
+}
+
+func resolveConfigPath(configPath string) (string, bool, error) {
+ if strings.TrimSpace(configPath) != "" {
+ return configPath, true, nil
+ }
+ if path := os.Getenv("HUMAN_GOAT_CONFIG"); path != "" {
+ return path, true, nil
+ }
+ dir, err := cliutil.ConfigDir()
+ if err != nil {
+ return "", false, err
+ }
+ return filepath.Join(dir, "config.toml"), false, nil
+}
+
+func LegacyConfigPath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolve legacy config path: %w", err)
+ }
+ return filepath.Join(home, ".config", "human-goat-pp-cli", "config.toml"), nil
+}
+
+func readConfigFile(path string, cfg *Config, owner string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return parseConfigData(data, cfg, path, owner)
+}
+
+func parseConfigData(data []byte, cfg *Config, path string, owner string) error {
+ if err := toml.Unmarshal(data, cfg); err != nil {
+ return fmt.Errorf("parsing %s %s: %w", owner, path, err)
+ }
+ cfg.configOwner = owner
+ return nil
+}
+func FileHasCredentialFields(path string) (bool, error) {
+ var cfg Config
+ if err := readConfigFile(path, &cfg, "credential probe"); err != nil {
+ return false, err
+ }
+ return cfg.hasCredentialFields(), nil
+}
+
+func (c *Config) AuthHeader() string {
+ if c.AuthHeaderVal != "" {
+ return c.AuthHeaderVal
+ }
+ // Env-var token wins over file-stored AccessToken (env > config convention).
+ if c.AccessToken != "" {
+ if c.AuthSource == "" {
+ c.AuthSource = "browser"
+ }
+ return ensureAuthScheme("Bearer", c.AccessToken)
+ }
+ return ""
+}
+
+// CookieCredential returns the raw stored cookie credential for cookie/composed
+// auth with no scheme prefix: the env-var-provided session string wins over the
+// file-stored browser cookie, mirroring AuthHeader's precedence. The value is
+// the cookie-jar string ("name=value; name=value") captured at login or exported
+// via the session env var, suitable for seeding an http.CookieJar so the
+// credential rides every request. Returns "" when no cookie credential is set.
+func (c *Config) CookieCredential() string {
+ return c.AccessToken
+}
+
+func applyAuthFormat(format string, replacements map[string]string) string {
+ if format == "" {
+ return ""
+ }
+ for key, value := range replacements {
+ format = strings.ReplaceAll(format, "{"+key+"}", value)
+ }
+ if strings.Contains(format, "{") {
+ return ""
+ }
+ return format
+}
+
+// ensureAuthScheme returns " " but skips the prefix when the
+// token already carries it case-insensitively, so a user who exports the
+// env var with the scheme already attached doesn't end up double-prefixed.
+// Empty scheme returns the token as-is.
+func ensureAuthScheme(scheme, token string) string {
+ if token == "" {
+ return ""
+ }
+ if scheme == "" {
+ return token
+ }
+ schemeWithSpace := scheme + " "
+ if len(token) >= len(schemeWithSpace) && strings.EqualFold(token[:len(schemeWithSpace)], schemeWithSpace) {
+ return token
+ }
+ return schemeWithSpace + token
+}
+
+func (c *Config) AgentcookieManagedByExternalStore() bool {
+ return false
+}
+
+func (c *Config) markAgentcookieManaged() {
+ c.AgentcookieManaged = true
+ c.CredentialSource = "agentcookie"
+}
+
+func (c *Config) hasCredentialFields() bool {
+ if c.AuthHeaderVal != "" ||
+ c.AccessToken != "" ||
+ c.RefreshToken != "" ||
+ c.ClientID != "" ||
+ c.ClientSecret != "" {
+ return true
+ }
+ return false
+}
+
+func (c *Config) clearCredentialFields() {
+ c.AuthHeaderVal = ""
+ c.AccessToken = ""
+ c.RefreshToken = ""
+ c.TokenExpiry = time.Time{}
+ c.ClientID = ""
+ c.ClientSecret = ""
+}
+
+func (c *Config) credentials() *cliutil.Credentials {
+ return &cliutil.Credentials{
+ AuthHeaderVal: c.AuthHeaderVal,
+ AccessToken: c.AccessToken,
+ RefreshToken: c.RefreshToken,
+ TokenExpiry: c.TokenExpiry,
+ ClientID: c.ClientID,
+ ClientSecret: c.ClientSecret,
+ }
+}
+
+func (c *Config) applyCredentials(creds *cliutil.Credentials) {
+ if creds == nil {
+ return
+ }
+ c.AuthHeaderVal = creds.AuthHeaderVal
+ c.AccessToken = creds.AccessToken
+ c.RefreshToken = creds.RefreshToken
+ c.TokenExpiry = creds.TokenExpiry
+ c.ClientID = creds.ClientID
+ c.ClientSecret = creds.ClientSecret
+}
+
+func (c *Config) saveCredentialsFirst() error {
+ if c.AgentcookieManagedByExternalStore() {
+ c.markAgentcookieManaged()
+ return nil
+ }
+ persisted := c.configForSave()
+ if err := cliutil.SaveCredentials(persisted.credentials()); err != nil {
+ return err
+ }
+ c.CredentialSource = "credentials file"
+ return nil
+}
+
+func (c *Config) SaveTokens(clientID, clientSecret, accessToken, refreshToken string, expiry time.Time) error {
+ c.ClientID = clientID
+ c.ClientSecret = clientSecret
+ c.AccessToken = accessToken
+ c.RefreshToken = refreshToken
+ c.TokenExpiry = expiry
+ delete(c.envOverrides, "ClientID")
+ delete(c.envOverrides, "ClientSecret")
+ delete(c.envOverrides, "AccessToken")
+ delete(c.envOverrides, "RefreshToken")
+ delete(c.envOverrides, "TokenExpiry")
+ c.updateFileConfigField("ClientID")
+ c.updateFileConfigField("ClientSecret")
+ c.updateFileConfigField("AccessToken")
+ c.updateFileConfigField("RefreshToken")
+ c.updateFileConfigField("TokenExpiry")
+ if err := c.saveCredentialsFirst(); err != nil {
+ return err
+ }
+ return c.save()
+}
+
+func (c *Config) ClearTokens() error {
+ // AuthHeader() falls back to the env-var-derived fields when AuthHeaderVal
+ // and AccessToken are empty, so dropping the working credential requires
+ // zeroing every emitted credential field, not just the OAuth trio.
+ // ClientID/ClientSecret persist to disk via SaveTokens for the oauth2
+ // and oauth2-cc flows, so logout must wipe them too; otherwise
+ // `auth login` can re-mint a new access token unattended.
+ c.AuthHeaderVal = ""
+ c.AccessToken = ""
+ c.RefreshToken = ""
+ c.TokenExpiry = time.Time{}
+ c.ClientID = ""
+ c.ClientSecret = ""
+ delete(c.envOverrides, "AuthHeaderVal")
+ delete(c.envOverrides, "AccessToken")
+ delete(c.envOverrides, "RefreshToken")
+ delete(c.envOverrides, "TokenExpiry")
+ delete(c.envOverrides, "ClientID")
+ delete(c.envOverrides, "ClientSecret")
+ c.updateFileConfigField("AuthHeaderVal")
+ c.updateFileConfigField("AccessToken")
+ c.updateFileConfigField("RefreshToken")
+ c.updateFileConfigField("TokenExpiry")
+ c.updateFileConfigField("ClientID")
+ c.updateFileConfigField("ClientSecret")
+ if c.AgentcookieManagedByExternalStore() {
+ c.markAgentcookieManaged()
+ // save() persists the full config (credential fields included) for
+ // agentcookie-managed stores, so the zeroed fields must be written
+ // back; returning early would leave the secrets on disk.
+ return c.save()
+ }
+ if err := cliutil.RemoveCredentials(); err != nil {
+ return err
+ }
+ return c.save()
+}
+
+func (c *Config) markEnvOverride(field string) {
+ if c.envOverrides == nil {
+ c.envOverrides = map[string]bool{}
+ }
+ c.envOverrides[field] = true
+}
+
+// cloneStringMap returns an independent copy of m (nil stays nil). The fileConfig
+// snapshot must not share reference-type map fields (such as Headers) with the
+// live config, or a later mutation to one would silently track in the other.
+func cloneStringMap(m map[string]string) map[string]string {
+ if m == nil {
+ return nil
+ }
+ out := make(map[string]string, len(m))
+ for k, v := range m {
+ out[k] = v
+ }
+ return out
+}
+
+func (c *Config) snapshotFileConfig() {
+ snapshot := *c
+ snapshot.envOverrides = nil
+ snapshot.fileConfig = nil
+ // *c is a shallow copy: map fields are reference types, so the snapshot would
+ // share them with c and silently track later mutations, defeating the
+ // isolation this snapshot exists to provide. Clone them.
+ snapshot.Headers = cloneStringMap(c.Headers)
+ c.fileConfig = &snapshot
+}
+
+func (c *Config) configForSave() Config {
+ out := *c
+ if c.fileConfig != nil {
+ }
+ out.envOverrides = nil
+ out.fileConfig = nil
+ return out
+}
+
+func (c *Config) updateFileConfigField(field string) {
+ if c.fileConfig == nil || c.envOverrides[field] {
+ return
+ }
+ switch field {
+ case "AuthHeaderVal":
+ c.fileConfig.AuthHeaderVal = c.AuthHeaderVal
+ case "AccessToken":
+ c.fileConfig.AccessToken = c.AccessToken
+ case "RefreshToken":
+ c.fileConfig.RefreshToken = c.RefreshToken
+ case "TokenExpiry":
+ c.fileConfig.TokenExpiry = c.TokenExpiry
+ case "ClientID":
+ c.fileConfig.ClientID = c.ClientID
+ case "ClientSecret":
+ c.fileConfig.ClientSecret = c.ClientSecret
+ }
+}
+
+func (c *Config) save() error {
+ persisted := c.configForSave()
+ var persist any = persisted
+ if !c.AgentcookieManagedByExternalStore() {
+ persist = persisted.persisted()
+ }
+ data, err := toml.Marshal(persist)
+ if err != nil {
+ return fmt.Errorf("marshaling config: %w", err)
+ }
+ if err := cliutil.AtomicWritePrivateFile(c.Path, data, 0o600, 0o700); err != nil {
+ return err
+ }
+ c.scrubLegacyCredentials()
+ if !c.AgentcookieManagedByExternalStore() {
+ persisted.clearCredentialFields()
+ }
+ c.fileConfig = &persisted
+ c.fileConfig.envOverrides = nil
+ c.fileConfig.fileConfig = nil
+ // persisted shares its map fields with c (configForSave shallow-copies *c),
+ // so isolate the stored fileConfig the same way snapshotFileConfig does;
+ // otherwise later mutations to c's maps leak into the on-disk snapshot.
+ c.fileConfig.Headers = cloneStringMap(c.fileConfig.Headers)
+ return nil
+}
+func (c *Config) scrubLegacyCredentials() {
+ if c.legacySourcePath == "" || c.legacySourcePath == c.Path {
+ return
+ }
+ if c.AgentcookieManagedByExternalStore() {
+ return
+ }
+ data, err := os.ReadFile(c.legacySourcePath)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ fmt.Fprintf(os.Stderr, "warning: cannot read legacy config to scrub credentials: %v\n", err)
+ }
+ return
+ }
+ var legacy Config
+ if err := toml.Unmarshal(data, &legacy); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: cannot parse legacy config to scrub credentials: %v\n", err)
+ return
+ }
+ legacy.clearCredentialFields()
+ scrubbed := legacy.persisted()
+ scrubbedData, err := toml.Marshal(scrubbed)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: cannot marshal scrubbed legacy config: %v\n", err)
+ return
+ }
+ if err := cliutil.AtomicWritePrivateFile(c.legacySourcePath, scrubbedData, 0o600, 0o700); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: cannot write scrubbed legacy config: %v\n", err)
+ }
+}
+
+type persistedConfig struct {
+ BaseURL string `toml:"base_url"`
+ Headers map[string]string `toml:"headers,omitempty"`
+}
+
+func (c *Config) persisted() persistedConfig {
+ return persistedConfig{
+ BaseURL: c.BaseURL,
+ Headers: c.Headers,
+ }
+}
+
+// Ensure strings import is used
+var _ = strings.ReplaceAll
diff --git a/library/productivity/human-goat/internal/learn/derive.go b/library/productivity/human-goat/internal/learn/derive.go
new file mode 100644
index 0000000000..6c1b2fa3ae
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/derive.go
@@ -0,0 +1,390 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// This file owns post-run flag-correction derivation: turning an
+// observed "failed flag, then same-session corrected retry" journal
+// sequence into an open flag_alias candidate. Derivation is read-only
+// against the journal (it never appends entries) and write-only
+// against the quarantined candidate store — nothing here touches the
+// verified learning rows.
+
+// flagCorrectionWindow bounds how long after a failed flag its
+// correcting success may arrive and still pair. A time window is used
+// rather than an entry-count window because the journal interleaves
+// sessions: a busy foreign session could push an honest correction out
+// of any fixed entry budget, while wall-clock distance is
+// session-independent. The same window decides when an unpaired
+// failure stops being "pending" (see DeriveFlagCorrections).
+const flagCorrectionWindow = 15 * time.Minute
+
+// CandidateStore is the narrow slice of the candidate store the
+// derivation pass needs. *store.Store satisfies it; the indirection
+// keeps derivation testable against the real SQLite store without the
+// pass owning open/close policy beyond its own lazy handle.
+type CandidateStore interface {
+ DeriveCandidate(class, payload, signature, queryFamily, commandPath string) (store.CandidateRow, bool, error)
+ Close() error
+}
+
+// flagCorrection is one paired observation ready to become a
+// flag_alias candidate.
+type flagCorrection struct {
+ CommandPath string
+ FailedFlag string
+ CorrectedFlag string
+ Example string
+ QueryFamily string
+}
+
+// flagAliasDerivedPayload is the candidate payload contract for
+// derived flag corrections. Example is a sanitized invocation string —
+// verb chain plus flag names only, never values.
+type flagAliasDerivedPayload struct {
+ CommandPath string `json:"command_path,omitempty"`
+ FailedFlag string `json:"failed_flag"`
+ CorrectedFlag string `json:"corrected_flag"`
+ Example string `json:"example,omitempty"`
+}
+
+// DeriveFlagCorrections runs one bounded tail scan of the invocation
+// journal from the persisted (segment, byte) offset and records every
+// paired flag correction as a flag_alias candidate observation. The
+// whole journal is never rescanned: the offset marks the consumed
+// prefix and only advances after a fully successful pass, so a failed
+// pass can never skip unprocessed entries.
+//
+// Pairing rule — ALL required:
+// - same session key;
+// - same subcommand verb chain;
+// - the success (exit 0) is the NEXT invocation of that command in
+// that session, within flagCorrectionWindow of the failure;
+// - the failed flag resolves to no real flag anywhere in the command
+// tree (flagExists probes the whole tree, dashes stripped);
+// - when the journal has a did-you-mean suggestion, that suggestion
+// names the correction and appears in the success entry's argv
+// shape;
+// - when no suggestion was recorded, the correction is the one new
+// success-entry flag absent from the failed entry's argv shape, or
+// the closest such flag by edit distance when multiple were added;
+// ties are skipped rather than guessed.
+//
+// Batches are consumed atomically. When the tail still holds an
+// unpaired failure fresh enough to pair (its correction may not be
+// journaled yet), the pass derives nothing and keeps the offset, so
+// the batch is reprocessed whole on the next post-run pass — every
+// observation is counted exactly once instead of a partial pass
+// re-bumping sightings later. Once the failure ages past the window it
+// is decided (no pair) and the offset moves on.
+//
+// The store opener is lazy: SQLite is touched only when the pass
+// actually paired something, so framework-only invocations never
+// create the learn database from this path.
+func DeriveFlagCorrections(openStore func() (CandidateStore, error), flagExists func(name string) bool) error {
+ if JournalCaptureDisabled() {
+ return nil
+ }
+ offset, err := LoadJournalOffset()
+ if err != nil {
+ return err
+ }
+ entries, next, err := ReadJournalFrom(offset)
+ if err != nil {
+ return err
+ }
+ if len(entries) == 0 && next == offset {
+ return nil
+ }
+ // Seed the per-session recall-family anchor from the FULL journal,
+ // not just the unprocessed batch: a prior derive pass on the recall
+ // invocation already advanced the offset past that recall entry, so
+ // the entry that carries the family is usually behind the batch by
+ // the time the failure/success pair completes. The seed is read-only
+ // context; pairing itself still happens only within the batch.
+ recallSeed := sessionRecallFamilies()
+ pairs, pending := pairFlagCorrections(entries, recallSeed, time.Now().UTC(), flagExists)
+ if pending {
+ return nil
+ }
+ if len(pairs) > 0 {
+ if openStore == nil {
+ return fmt.Errorf("derive flag corrections: no candidate store opener")
+ }
+ cs, err := openStore()
+ if err != nil {
+ return fmt.Errorf("derive flag corrections: open store: %w", err)
+ }
+ defer cs.Close()
+ for _, p := range pairs {
+ payload, err := json.Marshal(flagAliasDerivedPayload{
+ CommandPath: p.CommandPath,
+ FailedFlag: p.FailedFlag,
+ CorrectedFlag: p.CorrectedFlag,
+ Example: p.Example,
+ })
+ if err != nil {
+ return fmt.Errorf("derive flag corrections: marshal payload: %w", err)
+ }
+ signature := flagCorrectionSignature(p.CommandPath, p.FailedFlag, p.CorrectedFlag)
+ if _, _, err := cs.DeriveCandidate(
+ store.CandidateClassFlagAlias, string(payload), signature, p.QueryFamily, p.CommandPath,
+ ); err != nil {
+ // The offset is not advanced: the batch replays next
+ // pass rather than losing the observation.
+ return fmt.Errorf("derive flag corrections: %w", err)
+ }
+ }
+ }
+ return StoreJournalOffset(next)
+}
+
+// flagCorrectionSignature is the stable derivation signature for a
+// flag correction: the class plus its three identity fields, joined so
+// the same correction observed again converges on one candidate row.
+func flagCorrectionSignature(commandPath, failedFlag, correctedFlag string) string {
+ return strings.Join([]string{store.CandidateClassFlagAlias, commandPath, failedFlag, correctedFlag}, "|")
+}
+
+// pairFlagCorrections walks the batch in order applying the pairing
+// rule, and reports whether any unpaired failure is still fresh enough
+// that its correction may yet arrive (the caller then holds the whole
+// batch).
+func pairFlagCorrections(entries []JournalEntry, recallSeed map[string]string, now time.Time, flagExists func(name string) bool) ([]flagCorrection, bool) {
+ var pairs []flagCorrection
+ pending := false
+ // The family anchor for a derived candidate is the family of the
+ // session's most recent recall entry. Seed from the full-journal
+ // scan (recall entries commonly predate the batch), then let any
+ // fresher in-batch recall override it as the walk proceeds.
+ lastRecallFamily := map[string]string{}
+ for k, v := range recallSeed {
+ lastRecallFamily[k] = v
+ }
+ for i := range entries {
+ e := entries[i]
+ if len(e.Cmd) > 0 && e.Cmd[0] == "recall" && e.QueryFamily != "" {
+ lastRecallFamily[e.SessionKey] = e.QueryFamily
+ }
+ if e.ExitCode == 0 || e.FailedFlag == "" {
+ continue
+ }
+ failed := normalizeFlagToken(e.FailedFlag)
+ if failed == "" {
+ continue
+ }
+ if flagExists != nil && flagExists(strings.TrimLeft(failed, "-")) {
+ // A real flag somewhere in the tree (even on a sibling
+ // command) is a usage error, not a healable alias.
+ continue
+ }
+ failedAt, ok := parseJournalTime(e.TS)
+ if !ok {
+ continue
+ }
+ // The NEXT invocation of the same command in the same session
+ // decides the pairing; a later success never reaches back past
+ // an intervening retry.
+ nextIdx := -1
+ for j := i + 1; j < len(entries); j++ {
+ if entries[j].SessionKey == e.SessionKey && sameVerbChain(entries[j].Cmd, e.Cmd) {
+ nextIdx = j
+ break
+ }
+ }
+ if nextIdx < 0 {
+ if now.Sub(failedAt) <= flagCorrectionWindow {
+ pending = true
+ }
+ continue
+ }
+ success := entries[nextIdx]
+ if success.ExitCode != 0 {
+ continue
+ }
+ succeededAt, ok := parseJournalTime(success.TS)
+ if !ok || succeededAt.Before(failedAt) || succeededAt.Sub(failedAt) > flagCorrectionWindow {
+ continue
+ }
+ corrected := correctedFlagForPair(e, success)
+ if corrected == "" || corrected == failed {
+ continue
+ }
+ pairs = append(pairs, flagCorrection{
+ CommandPath: strings.Join(e.Cmd, " "),
+ FailedFlag: failed,
+ CorrectedFlag: corrected,
+ Example: sanitizedInvocationExample(success),
+ QueryFamily: lastRecallFamily[e.SessionKey],
+ })
+ }
+ return pairs, pending
+}
+
+// correctedFlagForPair chooses the correction for one decided
+// failure/success pair. Cobra's did-you-mean suggestion remains the
+// strongest signal; when Cobra had no suggestion, infer from the new
+// flag name introduced by the successful retry. Ambiguous deltas return
+// empty so derivation records no candidate.
+func correctedFlagForPair(failed, success JournalEntry) string {
+ suggested := normalizeFlagToken(failed.SuggestedFlag)
+ if suggested != "" {
+ if _, present := success.ArgvShape[strings.TrimLeft(suggested, "-")]; present {
+ return suggested
+ }
+ return ""
+ }
+ return inferCorrectedFlagFromNewSuccessFlag(failed, success)
+}
+
+func inferCorrectedFlagFromNewSuccessFlag(failed, success JournalEntry) string {
+ failedName := strings.TrimLeft(normalizeFlagToken(failed.FailedFlag), "-")
+ if failedName == "" {
+ return ""
+ }
+ failedShape := make(map[string]struct{}, len(failed.ArgvShape))
+ for name := range failed.ArgvShape {
+ name = strings.TrimLeft(normalizeFlagToken(name), "-")
+ if name != "" {
+ failedShape[name] = struct{}{}
+ }
+ }
+ bestName := ""
+ bestDistance := 0
+ ambiguous := false
+ for name := range success.ArgvShape {
+ name = strings.TrimLeft(normalizeFlagToken(name), "-")
+ if name == "" {
+ continue
+ }
+ if _, alreadyPresent := failedShape[name]; alreadyPresent {
+ continue
+ }
+ distance := editDistance(failedName, name)
+ if bestName == "" || distance < bestDistance {
+ bestName = name
+ bestDistance = distance
+ ambiguous = false
+ continue
+ }
+ if distance == bestDistance {
+ ambiguous = true
+ }
+ }
+ if bestName == "" || ambiguous {
+ return ""
+ }
+ return "--" + bestName
+}
+
+// sessionRecallFamilies scans the full journal and returns, per session
+// key, the family of that session's most recent recall entry. It exists
+// because derivation runs once per invocation and advances the offset,
+// so a recall entry is usually already behind the batch by the time the
+// failure/success pair it should anchor completes. Best-effort: a read
+// error yields an empty map and derived candidates simply carry no
+// anchor (the confirm path then falls back to the confirming session's
+// recall). Bounded by the journal's own retention caps.
+func sessionRecallFamilies() map[string]string {
+ entries, _, err := ReadJournalFrom(JournalOffset{})
+ if err != nil {
+ return map[string]string{}
+ }
+ families := make(map[string]string, 4)
+ for i := range entries {
+ e := entries[i]
+ if len(e.Cmd) > 0 && e.Cmd[0] == "recall" && e.QueryFamily != "" {
+ families[e.SessionKey] = e.QueryFamily
+ }
+ }
+ return families
+}
+
+// sanitizedInvocationExample renders a journal entry as a replayable
+// shape hint: the verb chain plus sorted flag names. Flag values never
+// appear (the journal never recorded them) and neither do positionals.
+func sanitizedInvocationExample(e JournalEntry) string {
+ parts := append([]string(nil), e.Cmd...)
+ names := make([]string, 0, len(e.ArgvShape))
+ for name := range e.ArgvShape {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ parts = append(parts, "--"+name)
+ }
+ return strings.Join(parts, " ")
+}
+
+// normalizeFlagToken canonicalizes a journaled flag reference to its
+// double-dash form so signatures and payloads stay byte-stable however
+// the source error message spelled it.
+func normalizeFlagToken(tok string) string {
+ tok = strings.TrimSpace(tok)
+ name := strings.TrimLeft(tok, "-")
+ if name == "" {
+ return ""
+ }
+ return "--" + name
+}
+
+func editDistance(a, b string) int {
+ ar := []rune(a)
+ br := []rune(b)
+ prev := make([]int, len(br)+1)
+ for j := range prev {
+ prev[j] = j
+ }
+ for i, ra := range ar {
+ curr := make([]int, len(br)+1)
+ curr[0] = i + 1
+ for j, rb := range br {
+ cost := 0
+ if ra != rb {
+ cost = 1
+ }
+ del := prev[j+1] + 1
+ ins := curr[j] + 1
+ sub := prev[j] + cost
+ curr[j+1] = del
+ if ins < curr[j+1] {
+ curr[j+1] = ins
+ }
+ if sub < curr[j+1] {
+ curr[j+1] = sub
+ }
+ }
+ prev = curr
+ }
+ return prev[len(br)]
+}
+
+func sameVerbChain(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func parseJournalTime(ts string) (time.Time, bool) {
+ t, err := time.Parse(time.RFC3339, ts)
+ if err != nil {
+ return time.Time{}, false
+ }
+ return t, true
+}
diff --git a/library/productivity/human-goat/internal/learn/derive_test.go b/library/productivity/human-goat/internal/learn/derive_test.go
new file mode 100644
index 0000000000..eaf4fd2b08
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/derive_test.go
@@ -0,0 +1,571 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Flag-correction derivation tests: real journal segments in a temp
+// state dir (via withJournalHome from journal_test.go) paired with a
+// real SQLite candidate store in t.TempDir. Lives in learn_test for
+// the same import-cycle reason as journal_test.go.
+package learn_test
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// deriveDBPath allocates a fresh candidate-store path per test.
+func deriveDBPath(t *testing.T) string {
+ t.Helper()
+ return filepath.Join(t.TempDir(), "learn.db")
+}
+
+// deriveOpener is the lazy store opener DeriveFlagCorrections expects;
+// each call opens its own handle so the pass owns close.
+func deriveOpener(dbPath string) func() (learn.CandidateStore, error) {
+ return func() (learn.CandidateStore, error) {
+ return store.Open(dbPath)
+ }
+}
+
+// listCandidateRows opens a fresh handle to read every candidate row
+// regardless of status, then closes it, so assertions never share a
+// connection with the derivation pass.
+func listCandidateRows(t *testing.T, dbPath string) []store.CandidateRow {
+ t.Helper()
+ s, err := store.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open store for assertions: %v", err)
+ }
+ defer s.Close()
+ rows, err := s.ListCandidates(store.ListCandidatesFilter{})
+ if err != nil {
+ t.Fatalf("list candidates: %v", err)
+ }
+ return rows
+}
+
+func rejectCandidateRow(t *testing.T, dbPath string, id int64) {
+ t.Helper()
+ s, err := store.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open store for reject: %v", err)
+ }
+ defer s.Close()
+ if _, err := s.RejectCandidate(id); err != nil {
+ t.Fatalf("reject candidate %d: %v", id, err)
+ }
+}
+
+func appendDeriveEntry(t *testing.T, entry learn.JournalEntry) {
+ t.Helper()
+ if err := learn.AppendJournalEntry(entry); err != nil {
+ t.Fatalf("append journal entry: %v", err)
+ }
+}
+
+// appendRawJournalLines appends raw bytes to the newest journal
+// segment, bypassing the entry marshaller, to plant garbage and torn
+// lines among real entries.
+func appendRawJournalLines(t *testing.T, raw string) {
+ t.Helper()
+ dir, err := learn.JournalDir()
+ if err != nil {
+ t.Fatalf("journal dir: %v", err)
+ }
+ des, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("read journal dir: %v", err)
+ }
+ newest := ""
+ for _, de := range des {
+ name := de.Name()
+ if strings.HasPrefix(name, "journal-") && strings.HasSuffix(name, ".jsonl") && name > newest {
+ newest = name
+ }
+ }
+ if newest == "" {
+ t.Fatal("no journal segment to append raw lines to")
+ }
+ f, err := os.OpenFile(filepath.Join(dir, newest), os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 -- temp test path
+ if err != nil {
+ t.Fatalf("open segment: %v", err)
+ }
+ defer f.Close()
+ if _, err := f.WriteString(raw); err != nil {
+ t.Fatalf("append raw: %v", err)
+ }
+}
+
+func deriveTS(ago time.Duration) string {
+ return time.Now().UTC().Add(-ago).Format(time.RFC3339)
+}
+
+// newDeriveCommandTree builds the small cobra tree the pairing rule is
+// probed against: root (persistent --json), `items list` (--dates),
+// and a `report` sibling that carries --date only when asked — the
+// sibling-flag negative.
+func newDeriveCommandTree(siblingHasDate bool) *cobra.Command {
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().Bool("json", false, "")
+ items := &cobra.Command{Use: "items"}
+ list := &cobra.Command{Use: "list"}
+ list.Flags().String("dates", "", "")
+ items.AddCommand(list)
+ root.AddCommand(items)
+ report := &cobra.Command{Use: "report"}
+ if siblingHasDate {
+ report.Flags().String("date", "", "")
+ }
+ root.AddCommand(report)
+ return root
+}
+
+// flagExistsInTree mirrors the emitted CLI's whole-tree flag probe.
+func flagExistsInTree(root *cobra.Command) func(string) bool {
+ var walk func(cmd *cobra.Command, name string) bool
+ walk = func(cmd *cobra.Command, name string) bool {
+ if cmd.Flags().Lookup(name) != nil || cmd.PersistentFlags().Lookup(name) != nil {
+ return true
+ }
+ for _, c := range cmd.Commands() {
+ if walk(c, name) {
+ return true
+ }
+ }
+ return false
+ }
+ return func(name string) bool {
+ return walk(root, strings.TrimLeft(name, "-"))
+ }
+}
+
+func failedDateEntry(session, ts string) learn.JournalEntry {
+ return learn.JournalEntry{
+ TS: ts,
+ SessionKey: session,
+ Cmd: []string{"items", "list"},
+ ArgvShape: map[string]string{"date": "str"},
+ ExitCode: 2,
+ ErrorClass: "usage",
+ FailedFlag: "--date",
+ SuggestedFlag: "--dates",
+ }
+}
+
+func correctedDatesEntry(session, ts string) learn.JournalEntry {
+ return learn.JournalEntry{
+ TS: ts,
+ SessionKey: session,
+ Cmd: []string{"items", "list"},
+ ArgvShape: map[string]string{"dates": "str", "json": "bool"},
+ ExitCode: 0,
+ }
+}
+
+func TestDeriveFlagCorrections_NoSuggestionInfersNewSuccessFlag(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ appendDeriveEntry(t, learn.JournalEntry{
+ TS: deriveTS(2 * time.Minute),
+ SessionKey: "sess-a",
+ Cmd: []string{"scoreboard"},
+ ArgvShape: map[string]string{"agent": "bool", "date": "str"},
+ ExitCode: 2,
+ ErrorClass: "usage",
+ FailedFlag: "--date",
+ })
+ appendDeriveEntry(t, learn.JournalEntry{
+ TS: deriveTS(time.Minute),
+ SessionKey: "sess-a",
+ Cmd: []string{"scoreboard"},
+ ArgvShape: map[string]string{"agent": "bool", "dates": "str"},
+ ExitCode: 0,
+ })
+
+ err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExistsInTree(newDeriveCommandTree(false)))
+ if err != nil {
+ t.Fatalf("DeriveFlagCorrections: %v", err)
+ }
+
+ rows := listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1 (%+v)", len(rows), rows)
+ }
+ if rows[0].Class != store.CandidateClassFlagAlias {
+ t.Errorf("class = %q, want %q", rows[0].Class, store.CandidateClassFlagAlias)
+ }
+ var payload struct {
+ CommandPath string `json:"command_path"`
+ FailedFlag string `json:"failed_flag"`
+ CorrectedFlag string `json:"corrected_flag"`
+ }
+ if err := json.Unmarshal([]byte(rows[0].Payload), &payload); err != nil {
+ t.Fatalf("payload not JSON: %v (%s)", err, rows[0].Payload)
+ }
+ if payload.CommandPath != "scoreboard" {
+ t.Errorf("payload command_path = %q, want scoreboard", payload.CommandPath)
+ }
+ if payload.FailedFlag != "--date" {
+ t.Errorf("payload failed_flag = %q, want --date", payload.FailedFlag)
+ }
+ if payload.CorrectedFlag != "--dates" {
+ t.Errorf("payload corrected_flag = %q, want --dates", payload.CorrectedFlag)
+ }
+ if rows[0].DerivationSignature != "flag_alias|scoreboard|--date|--dates" {
+ t.Errorf("signature = %q, want scoreboard --date -> --dates", rows[0].DerivationSignature)
+ }
+}
+
+func TestDeriveFlagCorrections_HappyPairDerivesOneCandidate(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ appendDeriveEntry(t, failedDateEntry("sess-a", deriveTS(2*time.Minute)))
+ appendDeriveEntry(t, correctedDatesEntry("sess-a", deriveTS(time.Minute)))
+
+ err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExistsInTree(newDeriveCommandTree(false)))
+ if err != nil {
+ t.Fatalf("DeriveFlagCorrections: %v", err)
+ }
+
+ rows := listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1 (%+v)", len(rows), rows)
+ }
+ row := rows[0]
+ if row.Class != store.CandidateClassFlagAlias {
+ t.Errorf("class = %q, want %q", row.Class, store.CandidateClassFlagAlias)
+ }
+ if row.Status != store.CandidateStatusOpen {
+ t.Errorf("status = %q, want open", row.Status)
+ }
+ if row.Sightings != 1 {
+ t.Errorf("sightings = %d, want 1", row.Sightings)
+ }
+ if row.CommandPath != "items list" {
+ t.Errorf("command_path anchor = %q, want %q", row.CommandPath, "items list")
+ }
+ if row.QueryFamily != "" {
+ t.Errorf("query_family anchor = %q, want empty without a session recall", row.QueryFamily)
+ }
+ wantSig := "flag_alias|items list|--date|--dates"
+ if row.DerivationSignature != wantSig {
+ t.Errorf("signature = %q, want %q", row.DerivationSignature, wantSig)
+ }
+ var payload struct {
+ CommandPath string `json:"command_path"`
+ FailedFlag string `json:"failed_flag"`
+ CorrectedFlag string `json:"corrected_flag"`
+ Example string `json:"example"`
+ }
+ if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil {
+ t.Fatalf("payload not JSON: %v (%s)", err, row.Payload)
+ }
+ if payload.CommandPath != "items list" {
+ t.Errorf("payload command_path = %q, want %q", payload.CommandPath, "items list")
+ }
+ if payload.FailedFlag != "--date" {
+ t.Errorf("payload failed_flag = %q, want --date", payload.FailedFlag)
+ }
+ if payload.CorrectedFlag != "--dates" {
+ t.Errorf("payload corrected_flag = %q, want --dates", payload.CorrectedFlag)
+ }
+ // Sanitized example: verb chain + sorted flag names, never values.
+ if payload.Example != "items list --dates --json" {
+ t.Errorf("payload example = %q, want %q", payload.Example, "items list --dates --json")
+ }
+}
+
+func TestDeriveFlagCorrections_FamilyAnchorFromSessionRecall(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ appendDeriveEntry(t, learn.JournalEntry{
+ TS: deriveTS(3 * time.Minute),
+ SessionKey: "sess-a",
+ Cmd: []string{"recall"},
+ ExitCode: 0,
+ QueryFamily: "fam-sample",
+ })
+ // A recall in a different session must not leak its family in.
+ appendDeriveEntry(t, learn.JournalEntry{
+ TS: deriveTS(3 * time.Minute),
+ SessionKey: "sess-other",
+ Cmd: []string{"recall"},
+ ExitCode: 0,
+ QueryFamily: "fam-foreign",
+ })
+ appendDeriveEntry(t, failedDateEntry("sess-a", deriveTS(2*time.Minute)))
+ appendDeriveEntry(t, correctedDatesEntry("sess-a", deriveTS(time.Minute)))
+
+ err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExistsInTree(newDeriveCommandTree(false)))
+ if err != nil {
+ t.Fatalf("DeriveFlagCorrections: %v", err)
+ }
+
+ rows := listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1", len(rows))
+ }
+ if rows[0].QueryFamily != "fam-sample" {
+ t.Errorf("query_family anchor = %q, want fam-sample", rows[0].QueryFamily)
+ }
+}
+
+func TestDeriveFlagCorrections_Negatives(t *testing.T) {
+ cases := []struct {
+ name string
+ entries []learn.JournalEntry
+ siblingHasDate bool
+ }{
+ {
+ name: "different session key",
+ entries: []learn.JournalEntry{
+ failedDateEntry("sess-a", deriveTS(30*time.Minute)),
+ correctedDatesEntry("sess-b", deriveTS(29*time.Minute)),
+ },
+ },
+ {
+ name: "different command",
+ entries: []learn.JournalEntry{
+ failedDateEntry("sess-a", deriveTS(30*time.Minute)),
+ {
+ TS: deriveTS(29 * time.Minute),
+ SessionKey: "sess-a",
+ Cmd: []string{"items", "get"},
+ ArgvShape: map[string]string{"dates": "str"},
+ ExitCode: 0,
+ },
+ },
+ },
+ {
+ name: "success beyond the pairing window",
+ entries: []learn.JournalEntry{
+ failedDateEntry("sess-a", deriveTS(40*time.Minute)),
+ correctedDatesEntry("sess-a", deriveTS(20*time.Minute)),
+ },
+ },
+ {
+ name: "next invocation of the command failed too",
+ entries: []learn.JournalEntry{
+ failedDateEntry("sess-a", deriveTS(30*time.Minute)),
+ failedDateEntry("sess-a", deriveTS(29*time.Minute)),
+ },
+ },
+ {
+ name: "corrected flag absent from the success argv shape",
+ entries: []learn.JournalEntry{
+ failedDateEntry("sess-a", deriveTS(30*time.Minute)),
+ {
+ TS: deriveTS(29 * time.Minute),
+ SessionKey: "sess-a",
+ Cmd: []string{"items", "list"},
+ ArgvShape: map[string]string{"json": "bool"},
+ ExitCode: 0,
+ },
+ },
+ },
+ {
+ name: "no suggestion and ambiguous new success flags",
+ entries: []learn.JournalEntry{
+ func() learn.JournalEntry {
+ e := failedDateEntry("sess-a", deriveTS(30*time.Minute))
+ e.SuggestedFlag = ""
+ return e
+ }(),
+ func() learn.JournalEntry {
+ e := correctedDatesEntry("sess-a", deriveTS(29*time.Minute))
+ e.ArgvShape["dater"] = "str"
+ return e
+ }(),
+ },
+ },
+ {
+ name: "failed flag is a real flag on a sibling command",
+ entries: []learn.JournalEntry{
+ failedDateEntry("sess-a", deriveTS(30*time.Minute)),
+ correctedDatesEntry("sess-a", deriveTS(29*time.Minute)),
+ },
+ siblingHasDate: true,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ for _, e := range tc.entries {
+ appendDeriveEntry(t, e)
+ }
+ err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExistsInTree(newDeriveCommandTree(tc.siblingHasDate)))
+ if err != nil {
+ t.Fatalf("DeriveFlagCorrections: %v", err)
+ }
+ if rows := listCandidateRows(t, dbPath); len(rows) != 0 {
+ t.Fatalf("candidate rows = %d, want 0 (%+v)", len(rows), rows)
+ }
+ })
+ }
+}
+
+func TestDeriveFlagCorrections_ReobservationBumpsSightingsNotRows(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ flagExists := flagExistsInTree(newDeriveCommandTree(false))
+
+ appendDeriveEntry(t, failedDateEntry("sess-a", deriveTS(10*time.Minute)))
+ appendDeriveEntry(t, correctedDatesEntry("sess-a", deriveTS(9*time.Minute)))
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("first pass: %v", err)
+ }
+
+ appendDeriveEntry(t, failedDateEntry("sess-b", deriveTS(5*time.Minute)))
+ appendDeriveEntry(t, correctedDatesEntry("sess-b", deriveTS(4*time.Minute)))
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("second pass: %v", err)
+ }
+
+ rows := listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1 (%+v)", len(rows), rows)
+ }
+ if rows[0].Sightings != 2 {
+ t.Errorf("sightings = %d, want 2 after re-observation", rows[0].Sightings)
+ }
+ if rows[0].Status != store.CandidateStatusOpen {
+ t.Errorf("status = %q, want open", rows[0].Status)
+ }
+}
+
+func TestDeriveFlagCorrections_RejectedSignatureTombstoneBlocksReplay(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ flagExists := flagExistsInTree(newDeriveCommandTree(false))
+
+ appendDeriveEntry(t, failedDateEntry("sess-a", deriveTS(10*time.Minute)))
+ appendDeriveEntry(t, correctedDatesEntry("sess-a", deriveTS(9*time.Minute)))
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("first pass: %v", err)
+ }
+ rows := listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1", len(rows))
+ }
+ rejectCandidateRow(t, dbPath, rows[0].ID)
+
+ appendDeriveEntry(t, failedDateEntry("sess-b", deriveTS(5*time.Minute)))
+ appendDeriveEntry(t, correctedDatesEntry("sess-b", deriveTS(4*time.Minute)))
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("post-reject pass: %v", err)
+ }
+
+ rows = listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1 (tombstone must absorb the replay)", len(rows))
+ }
+ if rows[0].Status != store.CandidateStatusRejected {
+ t.Errorf("status = %q, want rejected", rows[0].Status)
+ }
+ if rows[0].Sightings != 1 {
+ t.Errorf("sightings = %d, want 1 (tombstone must not bump)", rows[0].Sightings)
+ }
+}
+
+func TestDeriveFlagCorrections_OffsetAdvancesSoSecondPassDerivesNothing(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ flagExists := flagExistsInTree(newDeriveCommandTree(false))
+
+ appendDeriveEntry(t, failedDateEntry("sess-a", deriveTS(10*time.Minute)))
+ appendDeriveEntry(t, correctedDatesEntry("sess-a", deriveTS(9*time.Minute)))
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("first pass: %v", err)
+ }
+ offset, err := learn.LoadJournalOffset()
+ if err != nil {
+ t.Fatalf("load offset: %v", err)
+ }
+ if offset.Segment == "" {
+ t.Fatal("offset segment empty after a successful pass; offset must advance")
+ }
+
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("second pass: %v", err)
+ }
+ rows := listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1", len(rows))
+ }
+ if rows[0].Sightings != 1 {
+ t.Errorf("sightings = %d, want 1 (second pass over the same journal must derive nothing new)", rows[0].Sightings)
+ }
+}
+
+func TestDeriveFlagCorrections_FreshUnpairedFailureHoldsOffsetUntilCorrectionArrives(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ flagExists := flagExistsInTree(newDeriveCommandTree(false))
+
+ // The failure's post-run pass sees no correction yet: nothing
+ // derives and the offset holds so the pair survives across passes.
+ appendDeriveEntry(t, failedDateEntry("sess-a", deriveTS(0)))
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("failure-only pass: %v", err)
+ }
+ if rows := listCandidateRows(t, dbPath); len(rows) != 0 {
+ t.Fatalf("candidate rows = %d, want 0 before the correction lands", len(rows))
+ }
+ offset, err := learn.LoadJournalOffset()
+ if err != nil {
+ t.Fatalf("load offset: %v", err)
+ }
+ if offset != (learn.JournalOffset{}) {
+ t.Fatalf("offset = %+v, want zero while an unpaired fresh failure is pending", offset)
+ }
+
+ // The correcting invocation's own post-run pass pairs them.
+ appendDeriveEntry(t, correctedDatesEntry("sess-a", deriveTS(0)))
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("correction pass: %v", err)
+ }
+ rows := listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1 after the correction lands", len(rows))
+ }
+ offset, err = learn.LoadJournalOffset()
+ if err != nil {
+ t.Fatalf("load offset: %v", err)
+ }
+ if offset.Segment == "" {
+ t.Fatal("offset must advance once the pair is consumed")
+ }
+}
+
+func TestDeriveFlagCorrections_GarbageJournalLinesSkippedWithoutAborting(t *testing.T) {
+ withJournalHome(t)
+ dbPath := deriveDBPath(t)
+ flagExists := flagExistsInTree(newDeriveCommandTree(false))
+
+ appendDeriveEntry(t, failedDateEntry("sess-a", deriveTS(2*time.Minute)))
+ appendRawJournalLines(t, "this is not json\n{\"ts\": 12, broken\n")
+ appendDeriveEntry(t, correctedDatesEntry("sess-a", deriveTS(time.Minute)))
+ // A torn trailing write (no newline) stays unconsumed but must not
+ // abort the pass either.
+ appendRawJournalLines(t, "{\"ts\":\"torn")
+
+ if err := learn.DeriveFlagCorrections(deriveOpener(dbPath), flagExists); err != nil {
+ t.Fatalf("DeriveFlagCorrections: %v", err)
+ }
+ rows := listCandidateRows(t, dbPath)
+ if len(rows) != 1 {
+ t.Fatalf("candidate rows = %d, want 1 (garbage lines must be skipped)", len(rows))
+ }
+ if rows[0].DerivationSignature != "flag_alias|items list|--date|--dates" {
+ t.Errorf("signature = %q, want the paired correction", rows[0].DerivationSignature)
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/doc.go b/library/productivity/human-goat/internal/learn/doc.go
new file mode 100644
index 0000000000..31f4165d80
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/doc.go
@@ -0,0 +1,86 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package learn is the entity-aware teach/recall/pattern subsystem that
+// turns one-shot agent discoveries into reusable shortcuts. The goal:
+// a query that has been answered before should cost two calls (recall
+// + live fetch) instead of seven (a full discovery walk).
+//
+// # Subsystem purpose
+//
+// The CLI ships with a SQLite store. After each successful agent
+// response, the agent calls `teach --query "" --resource `
+// in the background; the next time the same (or structurally similar)
+// question arrives, `recall` returns the cached resource IDs and the
+// agent skips discovery. The whole subsystem is additive — every
+// learning path degrades to "no hit, run discovery normally" on the
+// slow path. Nothing here is allowed to make a cold query slower than
+// the unenhanced CLI did before; the recall floor is conservative
+// (Jaccard >= 0.6 by default), the entity validator filters false
+// positives back into a debug-only mismatches array, and --no-learn
+// short-circuits the whole pipeline for deterministic flows.
+//
+// # Config-per-call architecture
+//
+// All public functions that classify or normalize a query take an
+// explicit *entities.Config parameter. The Config is built once at
+// CLI startup from the spec-supplied LearnConfig (ticker patterns,
+// stopwords) and is read-shared by every Normalize / Extract /
+// ValidateResourceShape call thereafter. There is no package-level
+// singleton; consumer CLIs that want a per-process Config build it
+// once in their root command and pass it down. Tests build per-test
+// Configs so they don't leak state across tests.
+//
+// # Lifecycle: teach -> recall -> preseed -> patterns
+//
+// Four pipelines feed the same search_learnings + search_patterns +
+// entity_lookups tables:
+//
+// 1. Teach. The teach CLI command writes one search_learnings row
+// per (query_pattern, resource_type) tuple. The first teach lands
+// at confidence=2 (the recall skip floor); re-confirmations bump
+// confidence. Teach validates resource shape (teach.go, teach_log.go)
+// and emits warnings to the CLI state directory's teach.log when the
+// agent taught the wrong shape.
+//
+// 2. Recall. recall.go is the read path. Normalize -> Jaccard ->
+// entity-aware classify -> sort -> return. The envelope shape is
+// {found, results, mismatches, query_entities, normalized,
+// warnings}.
+//
+// 3. Preseed. preseed.go provides a generic per-source scanner
+// registry. Consumer CLIs register their own scanners (e.g.,
+// multi-outcome event families) via preseed.Register; nothing is
+// baked in.
+//
+// 4. Patterns. internal/learn/patterns generalizes two or more
+// structurally-similar teaches into a template. On a future query
+// whose entity isn't taught, recall falls back to patterns.Apply,
+// which substitutes via internal/learn/lookups and verifies the
+// candidate against the local resources table.
+//
+// # Store boundary
+//
+// internal/store never imports internal/learn. The v3 schema (set by
+// the U6 unit of the smart-learning plan) already provides the
+// search_learnings / search_patterns / entity_lookups tables
+// pre-created at the right shape; no v3->v4 migration of stored data
+// is needed and none is performed here. The learn package owns its
+// own data lifecycle: SeedFromConfig populates entity_lookups from
+// the spec at first start, teach writes search_learnings via direct
+// SQL against the generic shape, patterns.Extract writes
+// search_patterns after each teach. The store remains generic.
+//
+// # Lineage
+//
+// The recall envelope shape, entity-match decision matrix, and
+// pattern-extraction algorithm originate in prediction-goat-pp-cli.
+// The Printing Press port lifts them into a domain-neutral generator
+// template — no Kalshi / Polymarket / country_iso2 vocabulary; the
+// per-CLI shape is supplied entirely via the spec's LearnConfig
+// (ticker patterns, stopwords, entity-lookup seeds). The recipes
+// package was renamed to patterns; everything else is structurally
+// the same. See
+// docs/plans/2026-05-23-002-feat-generator-wide-self-learning-cli-plan.md
+// for the full design rationale.
+package learn
diff --git a/library/productivity/human-goat/internal/learn/entities/config.go b/library/productivity/human-goat/internal/learn/entities/config.go
new file mode 100644
index 0000000000..c5db9bee35
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/entities/config.go
@@ -0,0 +1,310 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package entities
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// defaultStopwords are the domain-agnostic English filler words that the
+// extractor strips regardless of consumer. Anything CLI-shape-specific
+// belongs in a per-CLI Config via RegisterStopwords, NOT in this list.
+var defaultStopwords = map[string]struct{}{
+ // Articles
+ "a": {}, "an": {}, "the": {},
+ // Be verbs
+ "is": {}, "are": {}, "was": {}, "were": {}, "be": {}, "been": {}, "being": {},
+ // Common prepositions
+ "of": {}, "to": {}, "in": {}, "on": {}, "at": {}, "for": {}, "with": {}, "from": {}, "by": {}, "about": {},
+ // Question words
+ "what": {}, "which": {}, "who": {}, "whom": {}, "whose": {}, "how": {}, "when": {}, "why": {}, "where": {},
+ // Modal / auxiliary verbs
+ "will": {}, "would": {}, "could": {}, "should": {}, "may": {}, "might": {}, "can": {}, "shall": {},
+ "do": {}, "does": {}, "did": {}, "have": {}, "has": {}, "had": {},
+ // Conjunctions / pronouns
+ "and": {}, "or": {}, "but": {}, "if": {}, "then": {}, "than": {},
+ "this": {}, "that": {}, "these": {}, "those": {}, "it": {}, "its": {},
+}
+
+// defaultSynonyms are the domain-neutral same-referent phrasing folds
+// (variant -> canonical) every Config ships with. Each pair MUST name
+// the same referent: "last night" and "yesterday" are the same day, so
+// they fold; "tonight" and "yesterday" are different days, so they
+// never may. Per-CLI additions go through RegisterSynonyms, NOT here.
+//
+// This map is duplicated as defaultQuerySynonyms in the store package
+// (which cannot import this one); a generator test pins the two copies
+// identical.
+var defaultSynonyms = map[string]string{
+ "last night": "yesterday",
+ "tonite": "tonight",
+ "to-day": "today",
+ "to-night": "tonight",
+ "to-morrow": "tomorrow",
+ "tmrw": "tomorrow",
+}
+
+// synonymRule is one compiled variant -> canonical fold. Variant and
+// canonical are pre-split into lowercase word sequences so multiword
+// variants ("last night") match across token boundaries.
+type synonymRule struct {
+ variant []string
+ canonical []string
+}
+
+// Config holds per-CLI registration for the entity extractor. Construct
+// with NewConfig(), then register CLI-specific ticker patterns and
+// stopwords. Pass the same Config to every Extract call within a CLI
+// process. Configs are not safe for concurrent mutation; build once at
+// startup and read-share thereafter.
+type Config struct {
+ tickerPatterns []*regexp.Regexp
+ stopwords map[string]struct{}
+ synonyms map[string]string
+ synonymRules []synonymRule
+}
+
+// NewConfig returns a Config preloaded with the domain-agnostic
+// default stopword set and the default same-referent synonym folds.
+// No ticker patterns are registered by default -- each consumer CLI
+// must add its own.
+func NewConfig() *Config {
+ cfg := &Config{
+ stopwords: make(map[string]struct{}, len(defaultStopwords)+16),
+ synonyms: make(map[string]string, len(defaultSynonyms)+8),
+ }
+ for w := range defaultStopwords {
+ cfg.stopwords[w] = struct{}{}
+ }
+ for v, c := range defaultSynonyms {
+ cfg.synonyms[v] = c
+ }
+ cfg.recompileSynonyms()
+ return cfg
+}
+
+// RegisterTickerPattern adds a compiled regex that recognizes one shape
+// of identifier this CLI uses. Multiple patterns can be registered;
+// they are tried in registration order on each token. Tokens that
+// match are returned in Result.Tickers (not Result.Entities).
+//
+// Patterns must anchor (^...$) when the CLI's identifiers can otherwise
+// substring-match an English word.
+func (c *Config) RegisterTickerPattern(re *regexp.Regexp) {
+ if re != nil {
+ c.tickerPatterns = append(c.tickerPatterns, re)
+ }
+}
+
+// RegisterStopwords adds CLI-shape stopwords on top of the default
+// set. Words are lower-cased on registration; matching at extract time
+// is case-insensitive.
+//
+// Use this for the vocabulary that wraps every query in your CLI's
+// domain without itself being an entity.
+func (c *Config) RegisterStopwords(words ...string) {
+ for _, w := range words {
+ w = strings.ToLower(strings.TrimSpace(w))
+ if w != "" {
+ c.stopwords[w] = struct{}{}
+ }
+ }
+}
+
+// RegisterSynonyms adds per-CLI same-referent phrasing folds (variant
+// -> canonical) on top of the default set. Keys and values are
+// lowercased on registration; entries with an empty side are dropped.
+// Folding is a single hop: canonical forms are never re-folded, so a
+// registration must map straight to the final phrasing.
+//
+// Same-referent ONLY. A synonym here means "these two phrasings name
+// the same thing" -- never "these are related". Registering a fold
+// across referents (e.g. two different days) silently corrupts every
+// stored query family that uses either phrasing.
+func (c *Config) RegisterSynonyms(synonyms map[string]string) {
+ changed := false
+ for v, canonical := range synonyms {
+ v = strings.ToLower(strings.TrimSpace(v))
+ canonical = strings.ToLower(strings.TrimSpace(canonical))
+ if v == "" || canonical == "" || v == canonical {
+ continue
+ }
+ c.synonyms[v] = canonical
+ changed = true
+ }
+ if changed {
+ c.recompileSynonyms()
+ }
+}
+
+// recompileSynonyms rebuilds the ordered rule list from the synonym
+// map. Longest variant first so a two-word variant wins over any
+// single-word prefix of it; ties break lexicographically so rule
+// order (and therefore folding) is deterministic.
+func (c *Config) recompileSynonyms() {
+ rules := make([]synonymRule, 0, len(c.synonyms))
+ for v, canonical := range c.synonyms {
+ variantTokens := strings.Fields(v)
+ canonicalTokens := strings.Fields(canonical)
+ if len(variantTokens) == 0 || len(canonicalTokens) == 0 {
+ continue
+ }
+ rules = append(rules, synonymRule{variant: variantTokens, canonical: canonicalTokens})
+ }
+ sort.Slice(rules, func(i, j int) bool {
+ if len(rules[i].variant) != len(rules[j].variant) {
+ return len(rules[i].variant) > len(rules[j].variant)
+ }
+ return strings.Join(rules[i].variant, " ") < strings.Join(rules[j].variant, " ")
+ })
+ c.synonymRules = rules
+}
+
+// FoldSynonyms rewrites every registered variant phrasing in query to
+// its canonical form, matching case-insensitively on whitespace-split
+// words (surrounding punctuation ignored, same trim set as Extract).
+// Unmatched words pass through untouched, so entity capitalization
+// survives. Runs BEFORE extraction so a folded word never reaches the
+// entity classifier -- sentence-initial "Last night" folds instead of
+// minting a spurious "Last" entity.
+func (c *Config) FoldSynonyms(query string) string {
+ if len(c.synonymRules) == 0 {
+ return query
+ }
+ words := strings.Fields(query)
+ if len(words) == 0 {
+ return query
+ }
+ lowered := make([]string, len(words))
+ for i, w := range words {
+ lowered[i] = strings.ToLower(trimPunct(w))
+ }
+ out := make([]string, 0, len(words))
+ folded := false
+ for i := 0; i < len(words); {
+ rule := c.matchSynonymAt(lowered, i)
+ if rule == nil {
+ out = append(out, words[i])
+ i++
+ continue
+ }
+ out = append(out, rule.canonical...)
+ i += len(rule.variant)
+ folded = true
+ }
+ if !folded {
+ return query
+ }
+ return strings.Join(out, " ")
+}
+
+// matchSynonymAt returns the first rule whose variant word sequence
+// matches lowered starting at position i, or nil. Rules are ordered
+// longest-variant-first, so multiword folds win over shorter ones.
+func (c *Config) matchSynonymAt(lowered []string, i int) *synonymRule {
+ for r := range c.synonymRules {
+ rule := &c.synonymRules[r]
+ if i+len(rule.variant) > len(lowered) {
+ continue
+ }
+ match := true
+ for k, vt := range rule.variant {
+ if lowered[i+k] != vt {
+ match = false
+ break
+ }
+ }
+ if match {
+ return rule
+ }
+ }
+ return nil
+}
+
+// FoldTokenBag applies the synonym folds to an UNORDERED token bag:
+// a rule fires when every variant token is present, regardless of
+// adjacency. Needed for stored query-family keys, which are
+// alphabetically sorted and so may separate a multiword variant's
+// tokens. Input tokens must already be lowercase. Returns the input
+// slice unchanged when nothing folds; order of the returned tokens is
+// unspecified (family-key callers re-sort).
+func (c *Config) FoldTokenBag(tokens []string) []string {
+ if len(c.synonymRules) == 0 || len(tokens) == 0 {
+ return tokens
+ }
+ counts := make(map[string]int, len(tokens))
+ for _, t := range tokens {
+ counts[t]++
+ }
+ changed := false
+ for r := range c.synonymRules {
+ rule := &c.synonymRules[r]
+ // Bound iterations by the bag size: single-hop folding can
+ // never fire more times than there are tokens, and the bound
+ // hard-stops any accidental variant/canonical overlap cycle.
+ for guard := 0; guard < len(tokens) && bagHasAll(counts, rule.variant); guard++ {
+ for _, vt := range rule.variant {
+ counts[vt]--
+ if counts[vt] == 0 {
+ delete(counts, vt)
+ }
+ }
+ for _, ct := range rule.canonical {
+ counts[ct]++
+ }
+ changed = true
+ }
+ }
+ if !changed {
+ return tokens
+ }
+ out := make([]string, 0, len(tokens))
+ for tok, n := range counts {
+ for k := 0; k < n; k++ {
+ out = append(out, tok)
+ }
+ }
+ return out
+}
+
+// bagHasAll reports whether counts holds at least one instance of
+// every token in want (counting multiplicity within want).
+func bagHasAll(counts map[string]int, want []string) bool {
+ need := make(map[string]int, len(want))
+ for _, w := range want {
+ need[w]++
+ }
+ for w, n := range need {
+ if counts[w] < n {
+ return false
+ }
+ }
+ return true
+}
+
+// IsStopword reports whether a lowercase token is a stopword for this
+// Config. Caller must lowercase the token first.
+func (c *Config) IsStopword(lower string) bool {
+ _, ok := c.stopwords[lower]
+ return ok
+}
+
+// isStopword is the internal-package alias retained for backward
+// compatibility with intra-package call sites.
+func (c *Config) isStopword(lower string) bool {
+ return c.IsStopword(lower)
+}
+
+// matchesTicker reports whether a token matches any registered ticker
+// pattern. Patterns are tried in registration order; first match wins.
+func (c *Config) matchesTicker(token string) bool {
+ for _, re := range c.tickerPatterns {
+ if re.MatchString(token) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/library/productivity/human-goat/internal/learn/entities/config_test.go b/library/productivity/human-goat/internal/learn/entities/config_test.go
new file mode 100644
index 0000000000..332fecbfd2
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/entities/config_test.go
@@ -0,0 +1,154 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package entities
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+)
+
+func TestNewConfig_HasDefaultStopwords(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ for _, sw := range []string{"the", "of", "is", "what"} {
+ if !cfg.isStopword(sw) {
+ t.Errorf("NewConfig should preload %q as a stopword", sw)
+ }
+ }
+}
+
+func TestNewConfig_NoTickerPatterns(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ if cfg.matchesTicker("TICKER-123") {
+ t.Errorf("NewConfig should ship no ticker patterns")
+ }
+}
+
+func TestRegisterStopwords_NoOpOnEmpty(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ cfg.RegisterStopwords("", " ")
+ if cfg.isStopword("") {
+ t.Errorf("empty stopword should not have been added")
+ }
+}
+
+func TestRegisterTickerPattern_NilIgnored(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ cfg.RegisterTickerPattern(nil)
+ cfg.RegisterTickerPattern(regexp.MustCompile(`^TICKER-[0-9]+$`))
+ if !cfg.matchesTicker("TICKER-123") {
+ t.Errorf("registered ticker should match")
+ }
+}
+
+func TestConfigsAreIsolated(t *testing.T) {
+ t.Parallel()
+ a := NewConfig()
+ b := NewConfig()
+ a.RegisterStopwords("alpha")
+ if b.isStopword("alpha") {
+ t.Errorf("config b should not see a's registered stopword")
+ }
+}
+
+func TestNewConfig_HasDefaultSynonyms(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ cases := []struct {
+ in string
+ want string
+ }{
+ // Multiword variant folds to a single canonical token.
+ {"last night", "yesterday"},
+ // Single-token spelling variants.
+ {"tonite", "tonight"},
+ {"to-day", "today"},
+ {"to-morrow", "tomorrow"},
+ // Embedded mid-sentence in a larger query.
+ {"why win last night then", "why win yesterday then"},
+ // Sentence-initial multiword variant with capitalization.
+ {"Last night something happened", "yesterday something happened"},
+ }
+ for _, tc := range cases {
+ if got := cfg.FoldSynonyms(tc.in); got != tc.want {
+ t.Errorf("FoldSynonyms(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestFoldSynonyms_NeverCrossesDayBoundary(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ // Same-referent only: "tonight" and "yesterday" name different days,
+ // so the default map must never fold one into the other.
+ for _, in := range []string{"tonight", "who plays tonight", "today"} {
+ got := cfg.FoldSynonyms(in)
+ if strings.Contains(got, "yesterday") {
+ t.Errorf("FoldSynonyms(%q) = %q crossed a day boundary", in, got)
+ }
+ }
+}
+
+func TestRegisterSynonyms_DeclaredFoldsUndeclaredDoesNot(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ cfg.RegisterSynonyms(map[string]string{"foo bar": "baz"})
+ if got := cfg.FoldSynonyms("check foo bar now"); got != "check baz now" {
+ t.Errorf("declared pair should fold: got %q", got)
+ }
+ if got := cfg.FoldSynonyms("check foo qux now"); got != "check foo qux now" {
+ t.Errorf("undeclared pair must not fold: got %q", got)
+ }
+}
+
+func TestRegisterSynonyms_IgnoresEmptyEntries(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ cfg.RegisterSynonyms(map[string]string{"": "canonical", "variant": "", " ": " "})
+ if got := cfg.FoldSynonyms("variant"); got != "variant" {
+ t.Errorf("empty-canonical registration must be a no-op: got %q", got)
+ }
+}
+
+func TestSynonymsAreIsolatedAcrossConfigs(t *testing.T) {
+ t.Parallel()
+ a := NewConfig()
+ b := NewConfig()
+ a.RegisterSynonyms(map[string]string{"foo bar": "baz"})
+ if got := b.FoldSynonyms("foo bar"); got != "foo bar" {
+ t.Errorf("config b should not see a's registered synonym; got %q", got)
+ }
+}
+
+func TestFoldTokenBag_NonAdjacentMultiwordVariant(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ // Family keys are alphabetically sorted token bags, so the tokens of
+ // a multiword variant may not be adjacent. Bag folding must still
+ // collapse them.
+ got := cfg.FoldTokenBag([]string{"last", "mood", "night"})
+ if joined := strings.Join(sortedCopy(got), " "); joined != "mood yesterday" {
+ t.Errorf("FoldTokenBag = %q, want %q", joined, "mood yesterday")
+ }
+}
+
+func TestFoldTokenBag_NoMatchNoChange(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ got := cfg.FoldTokenBag([]string{"tonight", "score"})
+ if joined := strings.Join(sortedCopy(got), " "); joined != "score tonight" {
+ t.Errorf("FoldTokenBag = %q, want %q (tonight must not fold)", joined, "score tonight")
+ }
+}
+
+func sortedCopy(in []string) []string {
+ out := append([]string(nil), in...)
+ sort.Strings(out)
+ return out
+}
diff --git a/library/productivity/human-goat/internal/learn/entities/extract.go b/library/productivity/human-goat/internal/learn/entities/extract.go
new file mode 100644
index 0000000000..03de21e5e0
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/entities/extract.go
@@ -0,0 +1,155 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package entities
+
+import (
+ "strings"
+ "unicode"
+)
+
+// Result is the parsed shape of a CLI query string from the perspective
+// of the learning subsystem. Each field carries a distinct semantic role:
+//
+// - Entities: identity-bearing tokens (proper names, place names, etc).
+// Used by match validation in the recall path: a learning whose
+// Entities don't overlap with the query's Entities is treated as a
+// mismatch even when non-entity Jaccard is high.
+//
+// - Tickers: CLI-specific identifier-shaped tokens, registered via
+// Config.RegisterTickerPattern. Kept separate from Entities so
+// downstream code can distinguish "user typed the literal id" from
+// "user typed an entity name."
+//
+// - NonEntityTokens: lowercase content tokens left over after entities,
+// tickers, and stopwords are removed. Used for token-set Jaccard
+// against the corresponding field on stored learnings. Sorted by the
+// caller for stable comparison.
+type Result struct {
+ Entities []string
+ Tickers []string
+ NonEntityTokens []string
+}
+
+// Extract pulls entity / ticker / non-entity tokens out of a query string
+// according to the provided Config. Passing nil cfg uses a Config with
+// the default stopword list and no ticker patterns.
+//
+// Rules, in order of precedence:
+//
+// 1. Token matches any Config.tickerPatterns -> Tickers
+// 2. Token is ALL-CAPS, length >= 2, alphanumeric -> Entities
+// 3. Token starts with uppercase letter:
+// a. At position 0 AND its lowercase form is a stopword -> drop
+// b. Otherwise -> Entities
+// 4. Token's lowercase form is a stopword -> drop
+// 5. Otherwise -> NonEntityTokens (lowercased)
+//
+// Punctuation surrounding tokens (.,?!:;'") is stripped before
+// classification. Internal hyphens and underscores are preserved so
+// tickers and slugs survive whitespace-tokenization.
+func Extract(query string, cfg *Config) Result {
+ if cfg == nil {
+ cfg = NewConfig()
+ }
+ rawTokens := strings.Fields(query)
+ result := Result{}
+
+ for i, raw := range rawTokens {
+ tok := trimPunct(raw)
+ if tok == "" {
+ continue
+ }
+
+ // 1. Ticker pattern -- highest precedence so a slug doesn't get
+ // mis-treated as a capitalized non-stopword.
+ if cfg.matchesTicker(tok) {
+ result.Tickers = append(result.Tickers, tok)
+ continue
+ }
+
+ lower := strings.ToLower(tok)
+
+ // 2. ALL-CAPS alphanumeric of length >= 2 -> entity, BUT
+ // explicit stopword registration overrides.
+ if len(tok) >= 2 && isAllCaps(tok) {
+ if cfg.isStopword(lower) {
+ continue
+ }
+ result.Entities = append(result.Entities, tok)
+ continue
+ }
+
+ // 3. Capitalized (first letter upper, has at least one lowercase).
+ if isCapitalized(tok) {
+ // 3a. Sentence-initial capitalized stopword: drop.
+ if i == 0 && cfg.isStopword(lower) {
+ continue
+ }
+ // 3b. Mid-sentence capitalization or non-stopword leading
+ // word: treat as entity.
+ result.Entities = append(result.Entities, tok)
+ continue
+ }
+
+ // 4. Stopword filter for lowercase tokens.
+ if cfg.isStopword(lower) {
+ continue
+ }
+
+ // 5. Everything else is a non-entity content token.
+ result.NonEntityTokens = append(result.NonEntityTokens, lower)
+ }
+
+ return result
+}
+
+// trimPunct strips a small set of sentence-punctuation characters from
+// both ends of a token. Internal hyphens and underscores are preserved
+// because they are load-bearing in tickers and slugs.
+func trimPunct(s string) string {
+ return strings.TrimFunc(s, func(r rune) bool {
+ switch r {
+ case '.', ',', '?', '!', ':', ';', '\'', '"', '(', ')', '[', ']', '{', '}':
+ return true
+ }
+ return false
+ })
+}
+
+// isAllCaps reports whether every letter in s is uppercase. Non-letter
+// runes (digits, hyphens) are allowed and don't disqualify; pure-digit
+// tokens return false because they aren't entities.
+func isAllCaps(s string) bool {
+ hasLetter := false
+ for _, r := range s {
+ if unicode.IsLetter(r) {
+ hasLetter = true
+ if !unicode.IsUpper(r) {
+ return false
+ }
+ }
+ }
+ return hasLetter
+}
+
+// isCapitalized reports whether the first letter rune of s is uppercase
+// AND there is at least one lowercase letter somewhere in s. The second
+// condition distinguishes "Alpha" from "ALPHA" (ALL-CAPS handled above).
+func isCapitalized(s string) bool {
+ firstUpper := false
+ hasLower := false
+ for i, r := range s {
+ if i == 0 {
+ if unicode.IsLetter(r) && unicode.IsUpper(r) {
+ firstUpper = true
+ continue
+ }
+ return false
+ }
+ if unicode.IsLetter(r) && unicode.IsLower(r) {
+ hasLower = true
+ }
+ }
+ return firstUpper && hasLower
+}
diff --git a/library/productivity/human-goat/internal/learn/entities/extract_test.go b/library/productivity/human-goat/internal/learn/entities/extract_test.go
new file mode 100644
index 0000000000..42329d0676
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/entities/extract_test.go
@@ -0,0 +1,161 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package entities
+
+import (
+ "reflect"
+ "regexp"
+ "testing"
+)
+
+// neutralConfig builds a Config with neutral test fixtures: a TICKER-NNN
+// ticker pattern, a slug-foo-bar-NNN slug pattern, and a few domain-shape
+// stopwords. Tests use this so the extraction rules can be exercised
+// without leaking real CLI vocabulary into the template tree.
+func neutralConfig() *Config {
+ cfg := NewConfig()
+ cfg.RegisterTickerPattern(regexp.MustCompile(`^TICKER-[A-Z0-9]+(-[A-Z0-9]+)*$`))
+ cfg.RegisterTickerPattern(regexp.MustCompile(`^slug-[a-z0-9-]+$`))
+ cfg.RegisterStopwords("rate", "rates", "score", "scores")
+ return cfg
+}
+
+func TestExtract_TableDriven(t *testing.T) {
+ t.Parallel()
+ cfg := neutralConfig()
+
+ cases := []struct {
+ name string
+ query string
+ wantEntities []string
+ wantTickers []string
+ wantNonEntities []string
+ }{
+ {
+ name: "ALL-CAPS entity classified",
+ query: "rate ALPHA score",
+ wantEntities: []string{"ALPHA"},
+ wantNonEntities: nil,
+ },
+ {
+ name: "Capitalized entity with possessive",
+ query: "what is Alpha's score",
+ wantEntities: []string{"Alpha's"},
+ wantNonEntities: nil,
+ },
+ {
+ name: "lowercase entity falls to non-entity",
+ query: "rate alpha score",
+ wantEntities: nil,
+ wantNonEntities: []string{"alpha"},
+ },
+ {
+ name: "ticker matched ahead of entity",
+ query: "what is TICKER-A123 about",
+ wantTickers: []string{"TICKER-A123"},
+ wantEntities: nil,
+ },
+ {
+ name: "slug recognized as ticker",
+ query: "look up slug-thing-foo-456",
+ wantTickers: []string{"slug-thing-foo-456"},
+ wantNonEntities: []string{"look", "up"},
+ },
+ {
+ name: "sentence-initial stopword dropped",
+ query: "The rate of Alpha",
+ wantEntities: []string{"Alpha"},
+ wantNonEntities: nil,
+ },
+ {
+ name: "mid-sentence stopword-shape capitalization stays entity",
+ query: "find Will Smith bio",
+ wantEntities: []string{"Will", "Smith"},
+ wantNonEntities: []string{"find", "bio"},
+ },
+ {
+ name: "punctuation stripped",
+ query: "rate, Alpha? score!",
+ wantEntities: []string{"Alpha"},
+ wantNonEntities: nil,
+ },
+ {
+ name: "multi-token entities",
+ query: "rate New Zealand score",
+ wantEntities: []string{"New", "Zealand"},
+ wantNonEntities: nil,
+ },
+ {
+ name: "single uppercase letter is not an entity",
+ query: "A B candidate",
+ wantEntities: nil,
+ wantNonEntities: []string{"b", "candidate"},
+ },
+ {
+ name: "empty input",
+ query: "",
+ wantEntities: nil,
+ wantNonEntities: nil,
+ },
+ {
+ name: "stopwords only",
+ query: "the of for",
+ wantEntities: nil,
+ wantNonEntities: nil,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := Extract(tc.query, cfg)
+ if !reflect.DeepEqual(nilIfEmpty(got.Entities), nilIfEmpty(tc.wantEntities)) {
+ t.Errorf("Entities = %v, want %v", got.Entities, tc.wantEntities)
+ }
+ if !reflect.DeepEqual(nilIfEmpty(got.Tickers), nilIfEmpty(tc.wantTickers)) {
+ t.Errorf("Tickers = %v, want %v", got.Tickers, tc.wantTickers)
+ }
+ if !reflect.DeepEqual(nilIfEmpty(got.NonEntityTokens), nilIfEmpty(tc.wantNonEntities)) {
+ t.Errorf("NonEntityTokens = %v, want %v", got.NonEntityTokens, tc.wantNonEntities)
+ }
+ })
+ }
+}
+
+func TestExtract_NilConfig(t *testing.T) {
+ t.Parallel()
+ got := Extract("rate Alpha score", nil)
+ if len(got.Entities) != 1 || got.Entities[0] != "Alpha" {
+ t.Errorf("nil config: entities = %v, want [Alpha]", got.Entities)
+ }
+}
+
+func TestExtract_PreservesOrderInResults(t *testing.T) {
+ t.Parallel()
+ cfg := neutralConfig()
+ got := Extract("ALPHA Bravo Charlie world", cfg)
+ want := []string{"ALPHA", "Bravo", "Charlie"}
+ if !reflect.DeepEqual(got.Entities, want) {
+ t.Errorf("Entities = %v, want %v (order matters)", got.Entities, want)
+ }
+}
+
+func TestRegisterStopwords_CaseInsensitive(t *testing.T) {
+ t.Parallel()
+ cfg := NewConfig()
+ cfg.RegisterStopwords("RATE")
+ got := Extract("rate Alpha RATE", cfg)
+ if len(got.NonEntityTokens) != 0 {
+ t.Errorf("RATE/rate should both be filtered; got %v", got.NonEntityTokens)
+ }
+ if len(got.Entities) != 1 || got.Entities[0] != "Alpha" {
+ t.Errorf("entities = %v, want [Alpha]", got.Entities)
+ }
+}
+
+func nilIfEmpty[T any](s []T) []T {
+ if len(s) == 0 {
+ return nil
+ }
+ return s
+}
diff --git a/library/productivity/human-goat/internal/learn/journal.go b/library/productivity/human-goat/internal/learn/journal.go
new file mode 100644
index 0000000000..13c3669125
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/journal.go
@@ -0,0 +1,462 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+)
+
+// The invocation journal records one entry per CLI invocation in dated
+// append-only JSONL segments under /learn/. Segments are
+// named journal-YYYYMMDD.jsonl so lexical order equals chronological
+// order. Cleanup only ever deletes whole oldest segments — never
+// in-place truncation, which would race concurrent appenders and
+// invalidate the (segment, byte) derivation offset — and never the
+// newest segment, which is the only one appenders write to.
+const (
+ journalDirName = "learn"
+ journalSegmentPrefix = "journal-"
+ journalSegmentSuffix = ".jsonl"
+ journalOffsetFileName = "journal.offset"
+
+ // Retention caps: whole segments older than the age cap are
+ // deleted; beyond that, oldest whole segments go until the total
+ // size fits. The newest segment always survives.
+ journalMaxSegmentAgeDays = 14
+ journalMaxTotalBytes = 5 << 20
+)
+
+// Journal env switches. The session var is trusted only under the
+// local single-user threat model; the NO_LEARN var is the master
+// disable shared with the teach/recall loop; NO_CAPTURE disables
+// journaling and derivation only (recall still surfaces candidates).
+const (
+ journalSessionEnvVar = "HUMAN_GOAT_LEARN_SESSION"
+ journalNoLearnEnvVar = "HUMAN_GOAT_NO_LEARN"
+ journalNoCaptureEnvVar = "HUMAN_GOAT_LEARN_NO_CAPTURE"
+)
+
+// Argv value classes. Flag values are classified, never serialized;
+// a flag whose name looks token-bearing is always "redacted" and its
+// value is never even inspected beyond consuming the token.
+const (
+ journalClassInt = "int"
+ journalClassStr = "str"
+ journalClassBool = "bool"
+ journalClassRedacted = "redacted"
+)
+
+// journalRedactedFlagPattern matches flag names that may carry
+// credentials. Over-matching is fine (a redacted class loses only the
+// value class, and values are never written anyway); under-matching
+// would leak a secret's shape.
+var journalRedactedFlagPattern = regexp.MustCompile(`(?i)token|key|secret|password|cookie|auth`)
+
+// JournalEntry is the on-disk record for one CLI invocation. Fields
+// keep omitempty so older readers parsing a newer-shape entry still
+// see a valid record. Cmd is the resolved subcommand verb chain only —
+// positional values are never recorded. ArgvShape maps flag names to
+// value classes; flag values are never serialized. QueryFamily and
+// UnresolvedEntities appear on learn-command entries only: both are
+// carved out of the freeform-text scrub by design because they already
+// persist locally in the learn tables.
+type JournalEntry struct {
+ TS string `json:"ts"`
+ SessionKey string `json:"session_key,omitempty"`
+ Cmd []string `json:"cmd,omitempty"`
+ ArgvShape map[string]string `json:"argv_shape,omitempty"`
+ // ExitCode stays explicit (no omitempty): 0 = success is a
+ // meaningful recorded outcome, not an absent field.
+ ExitCode int `json:"exit_code"`
+ ErrorClass string `json:"error_class,omitempty"`
+ FailedFlag string `json:"failed_flag,omitempty"`
+ SuggestedFlag string `json:"suggested_flag,omitempty"`
+ QueryFamily string `json:"query_family,omitempty"`
+ UnresolvedEntities []string `json:"unresolved_entities,omitempty"`
+}
+
+// JournalOffset is the persisted derivation cursor: a segment name
+// plus a byte offset into that segment. Cleanup deletes whole
+// segments, so a stored offset either stays valid or its segment
+// disappears entirely — reads then resume at the oldest surviving
+// segment. In-place truncation would silently invalidate Byte, which
+// is why it is banned.
+type JournalOffset struct {
+ Segment string `json:"segment"`
+ Byte int64 `json:"byte"`
+}
+
+// journalWarnOnce gates the single stderr warning for a failed journal
+// write. The journal is fail-open: it never fails or slows a command.
+var journalWarnOnce sync.Once
+
+// Pending learn-command context. The recall/teach command bodies run
+// before the post-run journal write in the same process; they annotate
+// the upcoming entry here (SetJournalLearnContext) and the write site
+// consumes it. Only learn-family commands should set this.
+var (
+ journalLearnCtxMu sync.Mutex
+ journalPendingQueryFamily string
+ journalPendingUnresolved []string
+)
+
+// SetJournalLearnContext stages the learn-command-only entry fields
+// for the invocation's journal entry. Callers pass the normalized
+// query family (never the raw query text) and any entity tokens that
+// failed to resolve at recall time.
+func SetJournalLearnContext(queryFamily string, unresolvedEntities []string) {
+ journalLearnCtxMu.Lock()
+ defer journalLearnCtxMu.Unlock()
+ journalPendingQueryFamily = queryFamily
+ journalPendingUnresolved = append([]string(nil), unresolvedEntities...)
+}
+
+func takeJournalLearnContext() (string, []string) {
+ journalLearnCtxMu.Lock()
+ defer journalLearnCtxMu.Unlock()
+ family, unresolved := journalPendingQueryFamily, journalPendingUnresolved
+ journalPendingQueryFamily, journalPendingUnresolved = "", nil
+ return family, unresolved
+}
+
+// JournalCaptureDisabled reports whether journaling must write
+// nothing for this process: verify/dogfood environments sit above all
+// other switches, then the master NO_LEARN env var, then the
+// capture-only NO_CAPTURE var. The --no-learn flag is handled by the
+// caller (the flag lives on the Cobra tree, not here).
+func JournalCaptureDisabled() bool {
+ if cliutil.IsVerifyEnv() || cliutil.IsDogfoodEnv() {
+ return true
+ }
+ return journalEnvTruthy(journalNoLearnEnvVar) || journalEnvTruthy(journalNoCaptureEnvVar)
+}
+
+func journalEnvTruthy(name string) bool {
+ v := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
+ return v == "true" || v == "1" || v == "yes"
+}
+
+// JournalSessionKey returns the session key for this invocation: the
+// session env var when set, else a parent-pid lineage string so all
+// commands spawned by the same agent shell share a key.
+func JournalSessionKey() string {
+ if v := strings.TrimSpace(os.Getenv(journalSessionEnvVar)); v != "" {
+ return v
+ }
+ return "ppid:" + strconv.Itoa(os.Getppid())
+}
+
+// JournalDir returns the journal directory (a learn/ subdir of the
+// CLI state dir), creating it on first call with 0o700.
+func JournalDir() (string, error) {
+ stateDir, err := cliutil.StateDir()
+ if err != nil {
+ return "", fmt.Errorf("journal: resolve state dir: %w", err)
+ }
+ dir := filepath.Join(stateDir, journalDirName)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("journal: mkdir %s: %w", dir, err)
+ }
+ return dir, nil
+}
+
+func journalSegmentNameFor(t time.Time) string {
+ return journalSegmentPrefix + t.UTC().Format("20060102") + journalSegmentSuffix
+}
+
+// journalSegmentDate parses the date embedded in a segment file name.
+func journalSegmentDate(name string) (time.Time, bool) {
+ if !strings.HasPrefix(name, journalSegmentPrefix) || !strings.HasSuffix(name, journalSegmentSuffix) {
+ return time.Time{}, false
+ }
+ stamp := strings.TrimSuffix(strings.TrimPrefix(name, journalSegmentPrefix), journalSegmentSuffix)
+ t, err := time.Parse("20060102", stamp)
+ if err != nil {
+ return time.Time{}, false
+ }
+ return t, true
+}
+
+// JournalInvocation records one invocation entry, filling timestamp
+// and session key when absent and merging any staged learn-command
+// context. Fail-open: a write failure warns to stderr once per
+// process and never fails or slows the command.
+func JournalInvocation(entry JournalEntry) {
+ if JournalCaptureDisabled() {
+ return
+ }
+ if entry.QueryFamily == "" && len(entry.UnresolvedEntities) == 0 {
+ entry.QueryFamily, entry.UnresolvedEntities = takeJournalLearnContext()
+ }
+ if err := AppendJournalEntry(entry); err != nil {
+ journalWarnOnce.Do(func() {
+ fmt.Fprintf(os.Stderr, "warning: learn journal write skipped: %v\n", err)
+ })
+ }
+}
+
+// AppendJournalEntry appends one entry to today's segment and then
+// runs best-effort segment cleanup. Returns nil without writing when
+// capture is disabled. The O_APPEND single-write pattern keeps
+// concurrent appenders (second goroutine or process) safe: each entry
+// is one atomic write well under the platform pipe-buffer bound.
+func AppendJournalEntry(entry JournalEntry) error {
+ if JournalCaptureDisabled() {
+ return nil
+ }
+ if entry.TS == "" {
+ entry.TS = time.Now().UTC().Format(time.RFC3339)
+ }
+ if entry.SessionKey == "" {
+ entry.SessionKey = JournalSessionKey()
+ }
+ dir, err := JournalDir()
+ if err != nil {
+ return err
+ }
+ line, err := json.Marshal(entry)
+ if err != nil {
+ return fmt.Errorf("journal: marshal: %w", err)
+ }
+ p := filepath.Join(dir, journalSegmentNameFor(time.Now()))
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) // #nosec G304 -- path derived from state dir
+ if err != nil {
+ return fmt.Errorf("journal: open %s: %w", p, err)
+ }
+ if _, err := f.Write(append(line, '\n')); err != nil {
+ f.Close()
+ return fmt.Errorf("journal: write: %w", err)
+ }
+ if err := f.Close(); err != nil {
+ return fmt.Errorf("journal: close: %w", err)
+ }
+ // Best-effort: retention failures never fail the append (the
+ // entry is already durable).
+ _ = cleanupJournalSegments(dir)
+ return nil
+}
+
+type journalSegment struct {
+ name string
+ size int64
+}
+
+func listJournalSegments(dir string) ([]journalSegment, error) {
+ dirEntries, err := os.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("journal: list %s: %w", dir, err)
+ }
+ var segs []journalSegment
+ for _, de := range dirEntries {
+ if de.IsDir() {
+ continue
+ }
+ if _, ok := journalSegmentDate(de.Name()); !ok {
+ continue
+ }
+ info, err := de.Info()
+ if err != nil {
+ continue
+ }
+ segs = append(segs, journalSegment{name: de.Name(), size: info.Size()})
+ }
+ sort.Slice(segs, func(i, j int) bool { return segs[i].name < segs[j].name })
+ return segs, nil
+}
+
+// cleanupJournalSegments enforces the retention caps by deleting whole
+// oldest segments. The newest segment — the only one appenders touch —
+// is never deleted, so caps can never race an appender. Never
+// truncates in place: truncation would corrupt a concurrent appender's
+// file position and invalidate persisted (segment, byte) offsets.
+func cleanupJournalSegments(dir string) error {
+ segs, err := listJournalSegments(dir)
+ if err != nil || len(segs) <= 1 {
+ return err
+ }
+ cutoff := time.Now().UTC().AddDate(0, 0, -journalMaxSegmentAgeDays)
+ var total int64
+ for _, s := range segs {
+ total += s.size
+ }
+ // Walk oldest-first, always preserving the newest segment.
+ for i := 0; i < len(segs)-1; i++ {
+ date, _ := journalSegmentDate(segs[i].name)
+ tooOld := date.Before(cutoff)
+ tooBig := total > journalMaxTotalBytes
+ if !tooOld && !tooBig {
+ break
+ }
+ if err := os.Remove(filepath.Join(dir, segs[i].name)); err != nil {
+ return fmt.Errorf("journal: remove segment %s: %w", segs[i].name, err)
+ }
+ total -= segs[i].size
+ }
+ return nil
+}
+
+// ReadJournalFrom returns every complete entry recorded at or after
+// offset, plus the advanced offset for the caller to persist. Reading
+// is tolerant: non-JSON lines are skipped, and a trailing line without
+// a newline (a torn concurrent write) is left unconsumed so the next
+// read picks it up whole. When the offset's segment no longer exists
+// (whole-segment cleanup deleted it), reading resumes at the oldest
+// surviving segment from byte 0 — offsets survive rollover by
+// construction because segments are only ever deleted whole.
+func ReadJournalFrom(offset JournalOffset) ([]JournalEntry, JournalOffset, error) {
+ dir, err := JournalDir()
+ if err != nil {
+ return nil, offset, err
+ }
+ segs, err := listJournalSegments(dir)
+ if err != nil {
+ return nil, offset, err
+ }
+ next := offset
+ var out []JournalEntry
+ for _, s := range segs {
+ // Lexical comparison equals chronological comparison for
+ // journal-YYYYMMDD.jsonl names.
+ if offset.Segment != "" && s.name < offset.Segment {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(dir, s.name)) // #nosec G304 -- listed from state dir
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return out, next, fmt.Errorf("journal: read %s: %w", s.name, err)
+ }
+ var consumed int64
+ if s.name == offset.Segment {
+ consumed = offset.Byte
+ if consumed > int64(len(data)) {
+ consumed = int64(len(data))
+ }
+ }
+ for {
+ idx := bytes.IndexByte(data[consumed:], '\n')
+ if idx < 0 {
+ break
+ }
+ line := data[consumed : consumed+int64(idx)]
+ consumed += int64(idx) + 1
+ if len(line) == 0 || line[0] != '{' {
+ continue
+ }
+ var entry JournalEntry
+ if err := json.Unmarshal(line, &entry); err != nil {
+ continue
+ }
+ out = append(out, entry)
+ }
+ next = JournalOffset{Segment: s.name, Byte: consumed}
+ }
+ return out, next, nil
+}
+
+// LoadJournalOffset reads the persisted derivation offset. A missing
+// offset file returns the zero offset (read everything) and no error.
+func LoadJournalOffset() (JournalOffset, error) {
+ dir, err := JournalDir()
+ if err != nil {
+ return JournalOffset{}, err
+ }
+ data, err := os.ReadFile(filepath.Join(dir, journalOffsetFileName)) // #nosec G304 -- path derived from state dir
+ if err != nil {
+ if os.IsNotExist(err) {
+ return JournalOffset{}, nil
+ }
+ return JournalOffset{}, fmt.Errorf("journal: read offset: %w", err)
+ }
+ var off JournalOffset
+ if err := json.Unmarshal(data, &off); err != nil {
+ // A corrupt offset file degrades to re-reading from the
+ // oldest segment rather than failing derivation forever.
+ return JournalOffset{}, nil
+ }
+ return off, nil
+}
+
+// StoreJournalOffset persists the derivation offset atomically.
+func StoreJournalOffset(offset JournalOffset) error {
+ dir, err := JournalDir()
+ if err != nil {
+ return err
+ }
+ data, err := json.Marshal(offset)
+ if err != nil {
+ return fmt.Errorf("journal: marshal offset: %w", err)
+ }
+ if err := cliutil.AtomicWritePrivateFile(filepath.Join(dir, journalOffsetFileName), data, 0o600, 0o700); err != nil {
+ return fmt.Errorf("journal: store offset: %w", err)
+ }
+ return nil
+}
+
+// JournalArgvShape reduces raw argv tokens to a flag-name -> value
+// class map. Flag values are classified, never recorded: a
+// token-bearing flag name (token/key/secret/password/cookie/auth,
+// case-insensitive) is always "redacted"; otherwise the value class is
+// int, str, or bool. isBoolFlag, when non-nil, resolves whether a flag
+// takes no value (the Cobra caller passes a registry lookup); when nil
+// a conservative heuristic consumes the next non-dash token as the
+// value. Positional tokens are never recorded.
+func JournalArgvShape(args []string, isBoolFlag func(name string) bool) map[string]string {
+ shape := map[string]string{}
+ for i := 0; i < len(args); i++ {
+ tok := args[i]
+ if !strings.HasPrefix(tok, "-") || tok == "-" || tok == "--" {
+ continue
+ }
+ name := strings.TrimLeft(tok, "-")
+ value := ""
+ hasValue := false
+ if eq := strings.Index(name, "="); eq >= 0 {
+ value = name[eq+1:]
+ name = name[:eq]
+ hasValue = true
+ } else if isBoolFlag != nil && isBoolFlag(name) {
+ // Registered boolean flag: never consumes the next token.
+ } else if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
+ value = args[i+1]
+ hasValue = true
+ i++
+ }
+ if name == "" {
+ continue
+ }
+ switch {
+ case journalRedactedFlagPattern.MatchString(name):
+ shape[name] = journalClassRedacted
+ case !hasValue:
+ shape[name] = journalClassBool
+ default:
+ if _, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
+ shape[name] = journalClassInt
+ } else {
+ shape[name] = journalClassStr
+ }
+ }
+ }
+ if len(shape) == 0 {
+ return nil
+ }
+ return shape
+}
diff --git a/library/productivity/human-goat/internal/learn/journal_test.go b/library/productivity/human-goat/internal/learn/journal_test.go
new file mode 100644
index 0000000000..7726c82796
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/journal_test.go
@@ -0,0 +1,539 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package learn_test drives the invocation journal end to end. It is
+// an external test package on purpose: the Execute()-level scenarios
+// (verb chains, parse failures, RunE errors) import internal/cli,
+// which itself imports internal/learn — an in-package test would be
+// an import cycle.
+package learn_test
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cli"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+)
+
+// withJournalHome isolates a test in a temp HOME with every journal
+// switch cleared, so entries land in a fresh state dir.
+func withJournalHome(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ t.Setenv("HOME", dir)
+ t.Setenv("HUMAN_GOAT_STATE_DIR", "")
+ t.Setenv("HUMAN_GOAT_HOME", "")
+ t.Setenv("XDG_STATE_HOME", "")
+ t.Setenv("PRINTING_PRESS_VERIFY", "")
+ t.Setenv("PRINTING_PRESS_DOGFOOD", "")
+ t.Setenv("HUMAN_GOAT_NO_LEARN", "")
+ t.Setenv("HUMAN_GOAT_LEARN_NO_CAPTURE", "")
+ t.Setenv("HUMAN_GOAT_LEARN_SESSION", "")
+ return dir
+}
+
+// runCLI executes the real command tree exactly as main.go would,
+// returning Execute's error. os.Args is global, so journal tests never
+// run in parallel.
+func runCLI(t *testing.T, args ...string) error {
+ t.Helper()
+ oldArgs := os.Args
+ os.Args = append([]string{"human-goat-pp-cli"}, args...)
+ defer func() { os.Args = oldArgs }()
+ return cli.Execute()
+}
+
+func journalDirForTest(t *testing.T) string {
+ t.Helper()
+ stateDir, err := cliutil.StateDir()
+ if err != nil {
+ t.Fatalf("StateDir() error = %v", err)
+ }
+ return filepath.Join(stateDir, "learn")
+}
+
+func readAllJournalEntries(t *testing.T) []learn.JournalEntry {
+ t.Helper()
+ entries, _, err := learn.ReadJournalFrom(learn.JournalOffset{})
+ if err != nil {
+ t.Fatalf("ReadJournalFrom: %v", err)
+ }
+ return entries
+}
+
+// journalRawBytes concatenates every segment's raw bytes so tests can
+// assert what never appears anywhere in the journal.
+func journalRawBytes(t *testing.T) string {
+ t.Helper()
+ dir := journalDirForTest(t)
+ names, err := os.ReadDir(dir)
+ if os.IsNotExist(err) {
+ return ""
+ }
+ if err != nil {
+ t.Fatalf("read journal dir: %v", err)
+ }
+ var sb strings.Builder
+ for _, de := range names {
+ if !strings.HasPrefix(de.Name(), "journal-") {
+ continue
+ }
+ b, err := os.ReadFile(filepath.Join(dir, de.Name())) // #nosec G304 -- temp test path
+ if err != nil {
+ t.Fatalf("read segment %s: %v", de.Name(), err)
+ }
+ sb.Write(b)
+ }
+ return sb.String()
+}
+
+func writeRawSegment(t *testing.T, name string, lines ...string) {
+ t.Helper()
+ dir, err := learn.JournalDir()
+ if err != nil {
+ t.Fatalf("JournalDir: %v", err)
+ }
+ body := strings.Join(lines, "\n")
+ if body != "" {
+ body += "\n"
+ }
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
+ t.Fatalf("write segment %s: %v", name, err)
+ }
+}
+
+func TestJournal_SuccessAppendsOneEntry(t *testing.T) {
+ withJournalHome(t)
+ if err := runCLI(t, "version"); err != nil {
+ t.Fatalf("version: %v", err)
+ }
+ entries := readAllJournalEntries(t)
+ if len(entries) != 1 {
+ t.Fatalf("want 1 entry, got %d", len(entries))
+ }
+ e := entries[0]
+ if len(e.Cmd) != 1 || e.Cmd[0] != "version" {
+ t.Errorf("cmd = %v, want [version]", e.Cmd)
+ }
+ if e.ExitCode != 0 {
+ t.Errorf("exit_code = %d, want 0", e.ExitCode)
+ }
+ if e.ErrorClass != "" {
+ t.Errorf("error_class = %q, want empty", e.ErrorClass)
+ }
+ if !strings.HasPrefix(e.SessionKey, "ppid:") {
+ t.Errorf("session_key = %q, want ppid lineage fallback", e.SessionKey)
+ }
+ if _, err := time.Parse(time.RFC3339, e.TS); err != nil {
+ t.Errorf("ts = %q not RFC3339: %v", e.TS, err)
+ }
+}
+
+func TestJournal_SessionKeyFromEnv(t *testing.T) {
+ withJournalHome(t)
+ t.Setenv("HUMAN_GOAT_LEARN_SESSION", "session-abc123")
+ if err := runCLI(t, "version"); err != nil {
+ t.Fatalf("version: %v", err)
+ }
+ entries := readAllJournalEntries(t)
+ if len(entries) != 1 || entries[0].SessionKey != "session-abc123" {
+ t.Errorf("session_key = %+v, want session-abc123", entries)
+ }
+}
+
+func TestJournal_UnknownFlagJournalsFailedFlagAndSuggestion(t *testing.T) {
+ withJournalHome(t)
+ err := runCLI(t, "version", "--jso")
+ if err == nil {
+ t.Fatalf("want unknown-flag error")
+ }
+ // The existing contract holds: usage exit code 2 and the
+ // did-you-mean hint attached to the returned error.
+ if code := cli.ExitCode(err); code != 2 {
+ t.Errorf("exit code = %d, want 2", code)
+ }
+ if !strings.Contains(err.Error(), "did you mean --json?") {
+ t.Errorf("err should keep the hint; got %q", err.Error())
+ }
+ entries := readAllJournalEntries(t)
+ if len(entries) != 1 {
+ t.Fatalf("want 1 entry, got %d", len(entries))
+ }
+ e := entries[0]
+ if e.FailedFlag != "--jso" {
+ t.Errorf("failed_flag = %q, want --jso", e.FailedFlag)
+ }
+ if e.SuggestedFlag != "--json" {
+ t.Errorf("suggested_flag = %q, want --json", e.SuggestedFlag)
+ }
+ if e.ErrorClass != "usage" {
+ t.Errorf("error_class = %q, want usage", e.ErrorClass)
+ }
+ if e.ExitCode != 2 {
+ t.Errorf("exit_code = %d, want 2", e.ExitCode)
+ }
+}
+
+// TestJournal_FailingRunEStillJournals is the no-PostRun proof: the
+// journal write runs after ExecuteC returns, so a command whose RunE
+// errors still journals (Cobra PostRun hooks would be skipped here).
+func TestJournal_FailingRunEStillJournals(t *testing.T) {
+ withJournalHome(t)
+ err := runCLI(t, "profile", "show", "no-such-profile-xyz")
+ if err == nil {
+ t.Fatalf("want RunE error for missing profile")
+ }
+ entries := readAllJournalEntries(t)
+ if len(entries) != 1 {
+ t.Fatalf("want 1 entry, got %d", len(entries))
+ }
+ e := entries[0]
+ if len(e.Cmd) != 2 || e.Cmd[0] != "profile" || e.Cmd[1] != "show" {
+ t.Errorf("cmd = %v, want [profile show]", e.Cmd)
+ }
+ if e.ErrorClass != "runtime" {
+ t.Errorf("error_class = %q, want runtime", e.ErrorClass)
+ }
+ if e.ExitCode == 0 {
+ t.Errorf("exit_code = 0, want non-zero")
+ }
+ // The positional value (and the error text carrying it) must
+ // never reach the journal bytes.
+ if raw := journalRawBytes(t); strings.Contains(raw, "no-such-profile-xyz") {
+ t.Errorf("positional value leaked into journal:\n%s", raw)
+ }
+}
+
+func TestJournal_DisableSwitchesWriteNothing(t *testing.T) {
+ cases := []struct {
+ name string
+ env map[string]string
+ args []string
+ }{
+ {name: "verify env", env: map[string]string{"PRINTING_PRESS_VERIFY": "1"}},
+ {name: "dogfood env", env: map[string]string{"PRINTING_PRESS_DOGFOOD": "1"}},
+ {name: "master no-learn env", env: map[string]string{"HUMAN_GOAT_NO_LEARN": "1"}},
+ {name: "capture-only env", env: map[string]string{"HUMAN_GOAT_LEARN_NO_CAPTURE": "1"}},
+ {name: "no-learn flag", args: []string{"--no-learn"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ withJournalHome(t)
+ for k, v := range tc.env {
+ t.Setenv(k, v)
+ }
+ if err := runCLI(t, append([]string{"version"}, tc.args...)...); err != nil {
+ t.Fatalf("version: %v", err)
+ }
+ if _, err := os.Stat(journalDirForTest(t)); !os.IsNotExist(err) {
+ raw := journalRawBytes(t)
+ if raw != "" {
+ t.Errorf("journal must stay empty under %s; got:\n%s", tc.name, raw)
+ }
+ }
+ })
+ }
+}
+
+func TestJournal_UnwritableDirWarnsOnceAndCommandSucceeds(t *testing.T) {
+ withJournalHome(t)
+ stateDir, err := cliutil.StateDir()
+ if err != nil {
+ t.Fatalf("StateDir: %v", err)
+ }
+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
+ t.Fatalf("mkdir state dir: %v", err)
+ }
+ // A regular file where the journal dir should be makes MkdirAll fail.
+ if err := os.WriteFile(filepath.Join(stateDir, "learn"), []byte("not a dir"), 0o600); err != nil {
+ t.Fatalf("plant blocker: %v", err)
+ }
+
+ oldStderr := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("pipe: %v", err)
+ }
+ os.Stderr = w
+ runErr := runCLI(t, "version")
+ w.Close()
+ os.Stderr = oldStderr
+ captured, _ := io.ReadAll(r)
+
+ if runErr != nil {
+ t.Fatalf("command must succeed despite journal failure; got %v", runErr)
+ }
+ if !strings.Contains(string(captured), "learn journal write skipped") {
+ t.Errorf("want one stderr warning; got %q", string(captured))
+ }
+}
+
+func TestJournal_TokenBearingFlagIsRedacted(t *testing.T) {
+ withJournalHome(t)
+ // --api-token is not a registered flag, so this rides the
+ // parse-failure path; the argv shape is built from os.Args either
+ // way and must classify without serializing the value.
+ _ = runCLI(t, "version", "--api-token", "sekretvalue123")
+ entries := readAllJournalEntries(t)
+ if len(entries) != 1 {
+ t.Fatalf("want 1 entry, got %d", len(entries))
+ }
+ if got := entries[0].ArgvShape["api-token"]; got != "redacted" {
+ t.Errorf("argv_shape[api-token] = %q, want redacted", got)
+ }
+ if raw := journalRawBytes(t); strings.Contains(raw, "sekretvalue123") {
+ t.Errorf("token value leaked into journal:\n%s", raw)
+ }
+}
+
+func TestJournal_UnknownCommandRecordsNoPositionalTokens(t *testing.T) {
+ withJournalHome(t)
+ err := runCLI(t, "frobnicate", "SENTINELPOSITIONAL", "--some-flag", "SENTINELVALUE")
+ if err == nil {
+ t.Fatalf("want unknown-command error")
+ }
+ entries := readAllJournalEntries(t)
+ if len(entries) != 1 {
+ t.Fatalf("want 1 entry, got %d", len(entries))
+ }
+ if len(entries[0].Cmd) != 0 {
+ t.Errorf("no command resolved; cmd = %v, want empty", entries[0].Cmd)
+ }
+ if entries[0].ErrorClass != "usage" {
+ t.Errorf("error_class = %q, want usage", entries[0].ErrorClass)
+ }
+ raw := journalRawBytes(t)
+ for _, leaked := range []string{"frobnicate", "SENTINELPOSITIONAL", "SENTINELVALUE"} {
+ if strings.Contains(raw, leaked) {
+ t.Errorf("token %q leaked into journal:\n%s", leaked, raw)
+ }
+ }
+}
+
+func TestJournalArgvShape_Classes(t *testing.T) {
+ boolFlags := map[string]bool{"json": true, "compact": true}
+ lookup := func(name string) bool { return boolFlags[name] }
+ cases := []struct {
+ name string
+ args []string
+ want map[string]string
+ }{
+ {
+ name: "int str bool",
+ args: []string{"--limit", "25", "--query", "hello", "--json", "list"},
+ want: map[string]string{"limit": "int", "query": "str", "json": "bool"},
+ },
+ {
+ name: "equals forms",
+ args: []string{"--limit=7", "--name=widget"},
+ want: map[string]string{"limit": "int", "name": "str"},
+ },
+ {
+ name: "redacted flags never classify values",
+ args: []string{"--api-token", "s3cr3t", "--auth-cookie=abc", "--password", "hunter2"},
+ want: map[string]string{"api-token": "redacted", "auth-cookie": "redacted", "password": "redacted"},
+ },
+ {
+ name: "positionals ignored",
+ args: []string{"list", "things", "--compact"},
+ want: map[string]string{"compact": "bool"},
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := learn.JournalArgvShape(tc.args, lookup)
+ if len(got) != len(tc.want) {
+ t.Fatalf("shape = %v, want %v", got, tc.want)
+ }
+ for k, v := range tc.want {
+ if got[k] != v {
+ t.Errorf("shape[%s] = %q, want %q", k, got[k], v)
+ }
+ }
+ // Value classes only, never values.
+ for k, v := range got {
+ switch v {
+ case "int", "str", "bool", "redacted":
+ default:
+ t.Errorf("shape[%s] = %q is not a value class", k, v)
+ }
+ }
+ })
+ }
+}
+
+func TestJournal_AgeCapDeletesOldestWholeSegmentConcurrently(t *testing.T) {
+ withJournalHome(t)
+ // A segment far past the 14-day age cap must be deleted whole by
+ // the next append's cleanup, while concurrent appenders to the
+ // newest segment survive untouched.
+ writeRawSegment(t, "journal-20200101.jsonl",
+ `{"ts":"2020-01-01T00:00:00Z","cmd":["old"],"exit_code":0}`,
+ `{"ts":"2020-01-01T00:00:01Z","cmd":["old"],"exit_code":0}`,
+ )
+
+ const perWriter = 20
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < perWriter; i++ {
+ _ = learn.AppendJournalEntry(learn.JournalEntry{Cmd: []string{"concurrent"}, ExitCode: 0})
+ }
+ }()
+ for i := 0; i < perWriter; i++ {
+ if err := learn.AppendJournalEntry(learn.JournalEntry{Cmd: []string{"main"}, ExitCode: 0}); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ }
+ wg.Wait()
+
+ dir := journalDirForTest(t)
+ if _, err := os.Stat(filepath.Join(dir, "journal-20200101.jsonl")); !os.IsNotExist(err) {
+ t.Errorf("age-capped segment should be deleted whole; stat err = %v", err)
+ }
+ entries := readAllJournalEntries(t)
+ if len(entries) != 2*perWriter {
+ t.Errorf("concurrent appenders must survive cleanup; want %d entries, got %d", 2*perWriter, len(entries))
+ }
+}
+
+func TestJournal_SizeCapDeletesOldestButNeverNewest(t *testing.T) {
+ withJournalHome(t)
+ // Two dated segments within the age window whose combined size
+ // exceeds the 5MB cap: the oldest is deleted whole, the newest is
+ // never touched (in-place truncation is banned).
+ pad := strings.Repeat("x", 1024)
+ bigLine := `{"ts":"2020-06-01T00:00:00Z","cmd":["bulk"],"exit_code":0,"error_class":"` + pad + `"}`
+ lines := make([]string, 0, 6*1024)
+ for i := 0; i < 6*1024; i++ {
+ lines = append(lines, bigLine)
+ }
+ yesterday := "journal-" + time.Now().UTC().AddDate(0, 0, -1).Format("20060102") + ".jsonl"
+ writeRawSegment(t, yesterday, lines...)
+
+ if err := learn.AppendJournalEntry(learn.JournalEntry{Cmd: []string{"today"}, ExitCode: 0}); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+
+ dir := journalDirForTest(t)
+ if _, err := os.Stat(filepath.Join(dir, yesterday)); !os.IsNotExist(err) {
+ t.Errorf("size-capped oldest segment should be deleted whole; stat err = %v", err)
+ }
+ today := "journal-" + time.Now().UTC().Format("20060102") + ".jsonl"
+ if _, err := os.Stat(filepath.Join(dir, today)); err != nil {
+ t.Errorf("newest segment must never be deleted: %v", err)
+ }
+ entries := readAllJournalEntries(t)
+ if len(entries) != 1 || len(entries[0].Cmd) != 1 || entries[0].Cmd[0] != "today" {
+ t.Errorf("surviving entries = %+v, want the single today entry", entries)
+ }
+}
+
+func TestJournal_OffsetSurvivesRolloverAsSegmentByte(t *testing.T) {
+ withJournalHome(t)
+ writeRawSegment(t, "journal-20200101.jsonl",
+ `{"ts":"2020-01-01T00:00:00Z","cmd":["old"],"exit_code":0}`,
+ `{"ts":"2020-01-01T00:00:01Z","cmd":["old"],"exit_code":1}`,
+ )
+
+ entries, off, err := learn.ReadJournalFrom(learn.JournalOffset{})
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if len(entries) != 2 {
+ t.Fatalf("want 2 entries, got %d", len(entries))
+ }
+ if off.Segment != "journal-20200101.jsonl" || off.Byte == 0 {
+ t.Fatalf("offset = %+v, want (journal-20200101.jsonl, >0)", off)
+ }
+ if err := learn.StoreJournalOffset(off); err != nil {
+ t.Fatalf("store offset: %v", err)
+ }
+ loaded, err := learn.LoadJournalOffset()
+ if err != nil {
+ t.Fatalf("load offset: %v", err)
+ }
+ if loaded != off {
+ t.Fatalf("offset roundtrip = %+v, want %+v", loaded, off)
+ }
+
+ // Appending today triggers cleanup, which deletes the age-capped
+ // 2020 segment whole. The persisted (segment, byte) offset must
+ // keep working: everything strictly after it — the new segment —
+ // is returned, and nothing is double-read.
+ if err := learn.AppendJournalEntry(learn.JournalEntry{Cmd: []string{"fresh"}, ExitCode: 0}); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ newEntries, next, err := learn.ReadJournalFrom(loaded)
+ if err != nil {
+ t.Fatalf("read from persisted offset: %v", err)
+ }
+ if len(newEntries) != 1 || len(newEntries[0].Cmd) != 1 || newEntries[0].Cmd[0] != "fresh" {
+ t.Fatalf("entries after rollover = %+v, want the single fresh entry", newEntries)
+ }
+ if next.Segment == loaded.Segment || next.Byte == 0 {
+ t.Errorf("offset should advance to the new segment; got %+v", next)
+ }
+}
+
+func TestJournal_LearnContextCarveOutConsumedOnce(t *testing.T) {
+ withJournalHome(t)
+ // Learn commands stage query_family + unresolved_entities (the
+ // two fields carved out of the freeform-text scrub); the next
+ // journal write consumes them exactly once.
+ learn.SetJournalLearnContext("end of season", []string{"WIDGET-X"})
+ learn.JournalInvocation(learn.JournalEntry{Cmd: []string{"recall"}, ExitCode: 0})
+ learn.JournalInvocation(learn.JournalEntry{Cmd: []string{"version"}, ExitCode: 0})
+ entries := readAllJournalEntries(t)
+ if len(entries) != 2 {
+ t.Fatalf("want 2 entries, got %d", len(entries))
+ }
+ if entries[0].QueryFamily != "end of season" || len(entries[0].UnresolvedEntities) != 1 || entries[0].UnresolvedEntities[0] != "WIDGET-X" {
+ t.Errorf("learn-command entry = %+v, want staged context", entries[0])
+ }
+ if entries[1].QueryFamily != "" || len(entries[1].UnresolvedEntities) != 0 {
+ t.Errorf("context must be consumed once; second entry = %+v", entries[1])
+ }
+}
+
+func TestJournal_ReaderToleratesTornAndForeignLines(t *testing.T) {
+ withJournalHome(t)
+ today := "journal-" + time.Now().UTC().Format("20060102") + ".jsonl"
+ writeRawSegment(t, today,
+ `{"ts":"2026-01-01T00:00:00Z","cmd":["ok"],"exit_code":0}`,
+ "not json at all",
+ )
+ // A torn trailing write (no newline) must not be consumed.
+ dir := journalDirForTest(t)
+ f, err := os.OpenFile(filepath.Join(dir, today), os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 -- temp test path
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ if _, err := f.WriteString(`{"ts":"2026-01-01T00:00:01Z","cmd":["torn`); err != nil {
+ t.Fatalf("write torn: %v", err)
+ }
+ f.Close()
+
+ entries, off, err := learn.ReadJournalFrom(learn.JournalOffset{})
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if len(entries) != 1 || entries[0].Cmd[0] != "ok" {
+ t.Fatalf("entries = %+v, want the single ok entry", entries)
+ }
+ info, err := os.Stat(filepath.Join(dir, today))
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ if off.Byte >= info.Size() {
+ t.Errorf("offset %d must stop before the torn tail (size %d)", off.Byte, info.Size())
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/lookups/seeds.go b/library/productivity/human-goat/internal/learn/lookups/seeds.go
new file mode 100644
index 0000000000..8ebd133602
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/lookups/seeds.go
@@ -0,0 +1,83 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package lookups
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// SeedConfig is the canonical-+-values shape supplied by the consumer
+// CLI's spec (LearnConfig.EntityLookupSeeds). Use SeedFromConfig to
+// materialize one or many of these into the entity_lookups table on
+// first start (or on every start; INSERT OR IGNORE is idempotent).
+//
+// Why a map keyed by kind: it matches the spec shape directly and
+// keeps the per-CLI seeding wiring trivial. The runtime LookupRow
+// shape is denormalized at insert time.
+type SeedConfig struct {
+ Canonical string
+ Values []string
+}
+
+// SeedFromConfig walks the supplied per-kind seed map and inserts
+// every (kind, canonical, alias, "seeded") row via SeedBatch. Returns
+// the number of rows actually inserted (i.e., not skipped by the
+// idempotency check).
+//
+// The whole batch runs inside a single transaction so a partial seed
+// can't leave the DB in a state where some kinds are populated and
+// others aren't. db must be non-nil; an empty map is a no-op and
+// returns (0, nil).
+//
+// This is the canonical seeding entrypoint when the consumer CLI
+// derives its seed data from the spec at print time (the generator
+// stamps the per-CLI seed map and a call to SeedFromConfig at first
+// start). Hand-built per-CLI seeders should call SeedBatch directly.
+func SeedFromConfig(db *sql.DB, seedsByKind map[string][]SeedConfig) (int, error) {
+ if db == nil {
+ return 0, errors.New("lookups.SeedFromConfig: db is nil")
+ }
+ if len(seedsByKind) == 0 {
+ return 0, nil
+ }
+ tx, err := db.Begin()
+ if err != nil {
+ return 0, fmt.Errorf("lookups.SeedFromConfig begin: %w", err)
+ }
+ defer tx.Rollback()
+
+ rows := make([]LookupRow, 0)
+ for kind, seeds := range seedsByKind {
+ for _, s := range seeds {
+ // The canonical itself counts as a value of itself so a
+ // query that types the canonical name resolves back. The
+ // PRIMARY KEY constraint protects against duplicates if the
+ // spec author also lists the canonical in Values.
+ rows = append(rows, LookupRow{
+ Kind: kind,
+ Canonical: s.Canonical,
+ Value: s.Canonical,
+ Source: "seeded",
+ })
+ for _, value := range s.Values {
+ rows = append(rows, LookupRow{
+ Kind: kind,
+ Canonical: s.Canonical,
+ Value: value,
+ Source: "seeded",
+ })
+ }
+ }
+ }
+ inserted, err := SeedBatch(tx, rows)
+ if err != nil {
+ return inserted, err
+ }
+ if err := tx.Commit(); err != nil {
+ return inserted, fmt.Errorf("lookups.SeedFromConfig commit: %w", err)
+ }
+ return inserted, nil
+}
diff --git a/library/productivity/human-goat/internal/learn/lookups/seeds_test.go b/library/productivity/human-goat/internal/learn/lookups/seeds_test.go
new file mode 100644
index 0000000000..349e75c99c
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/lookups/seeds_test.go
@@ -0,0 +1,103 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package lookups
+
+import (
+ "testing"
+)
+
+func TestSeedFromConfig_InsertsCanonicalAndValues(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ seeds := map[string][]SeedConfig{
+ "team_code": {
+ {Canonical: "Alpha", Values: []string{"ALP", "Alpha United"}},
+ {Canonical: "Bravo", Values: []string{"BRV"}},
+ },
+ }
+ inserted, err := SeedFromConfig(db, seeds)
+ if err != nil {
+ t.Fatalf("SeedFromConfig: %v", err)
+ }
+ // Expect 5 rows: 2 canonicals + 3 values.
+ if inserted != 5 {
+ t.Errorf("inserted = %d, want 5", inserted)
+ }
+ got, ok, err := Lookup(db, "team_code", "Alpha")
+ if err != nil || !ok {
+ t.Fatalf("Lookup Alpha: ok=%v err=%v", ok, err)
+ }
+ if got == "" {
+ t.Errorf("Lookup Alpha returned empty")
+ }
+ all, err := LookupAll(db, "team_code", "Alpha")
+ if err != nil {
+ t.Fatalf("LookupAll: %v", err)
+ }
+ if len(all) != 3 {
+ t.Errorf("LookupAll = %v (len %d), want 3", all, len(all))
+ }
+}
+
+func TestSeedFromConfig_Idempotent(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ seeds := map[string][]SeedConfig{
+ "team_code": {{Canonical: "Alpha", Values: []string{"ALP"}}},
+ }
+ n1, err := SeedFromConfig(db, seeds)
+ if err != nil {
+ t.Fatalf("first SeedFromConfig: %v", err)
+ }
+ if n1 != 2 {
+ t.Errorf("first inserted = %d, want 2", n1)
+ }
+ n2, err := SeedFromConfig(db, seeds)
+ if err != nil {
+ t.Fatalf("second SeedFromConfig: %v", err)
+ }
+ if n2 != 0 {
+ t.Errorf("second inserted = %d, want 0 (idempotent)", n2)
+ }
+}
+
+func TestSeedFromConfig_NilDBErrors(t *testing.T) {
+ t.Parallel()
+ _, err := SeedFromConfig(nil, map[string][]SeedConfig{"k": {{Canonical: "X"}}})
+ if err == nil {
+ t.Errorf("nil db: want error, got nil")
+ }
+}
+
+func TestSeedFromConfig_EmptyMapNoop(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ n, err := SeedFromConfig(db, nil)
+ if err != nil {
+ t.Fatalf("nil map: %v", err)
+ }
+ if n != 0 {
+ t.Errorf("nil map inserted = %d, want 0", n)
+ }
+}
+
+func TestSeedFromConfig_CanonicalAsItsOwnValue(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ // Author lists the canonical explicitly in Values; PK constraint
+ // should silence the duplicate.
+ seeds := map[string][]SeedConfig{
+ "team_code": {{Canonical: "Alpha", Values: []string{"Alpha", "ALP"}}},
+ }
+ inserted, err := SeedFromConfig(db, seeds)
+ if err != nil {
+ t.Fatalf("SeedFromConfig: %v", err)
+ }
+ // (Alpha, Alpha) + (Alpha, ALP) + auto-added (Alpha, Alpha) -> first
+ // dedupes to 1 because the PK silences the second insert. Expect
+ // 2 rows total.
+ if inserted != 2 {
+ t.Errorf("inserted = %d, want 2 (PK silences canonical dup)", inserted)
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/lookups/store.go b/library/productivity/human-goat/internal/learn/lookups/store.go
new file mode 100644
index 0000000000..652a1c714e
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/lookups/store.go
@@ -0,0 +1,545 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package lookups
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "unicode"
+)
+
+// LookupRow is the canonical seed-row shape used by both the seeds
+// driver and any runtime upsert path. The columns mirror the
+// entity_lookups schema: (kind, canonical, value, source).
+//
+// Source is the provenance string: "seeded" for migration-time inserts,
+// "taught" for user / runtime adds, "inferred" for rows the pattern
+// engine derives at recall time, "synced" for rows the post-sync
+// scanner derives from already-synced local resources.
+type LookupRow struct {
+ Kind string
+ Canonical string
+ Value string
+ Source string
+}
+
+// Source provenance strings for entity_lookups rows, listed in the
+// priority order Lookup and LookupAll apply (highest first). Both
+// functions hardcode this ladder in their CASE expressions; keep the
+// two SQL sites and this constant block in sync.
+const (
+ SourceTaught = "taught"
+ SourceInferred = "inferred"
+ SourceSynced = "synced"
+ SourceSeeded = "seeded"
+)
+
+// computedKinds enumerates the kinds whose Lookup result is derived
+// purely from the canonical input by string transform, with no
+// reference to the database. Adding one is a source-code change
+// (the transform itself must be implemented in computedLookup below).
+var computedKinds = map[string]struct{}{
+ "lowercase": {},
+ "uppercase": {},
+ "kebab-case": {},
+ "capitalize-first": {},
+ "slug": {},
+}
+
+// nonSlugRune matches every character that is NOT a lowercase ASCII
+// letter, digit, or hyphen. Used by the slug computed kind to strip
+// punctuation after kebab-casing.
+var nonSlugRune = regexp.MustCompile(`[^a-z0-9-]+`)
+
+// IsComputedKind reports whether kind is resolved by an in-package
+// string transform instead of by a row in entity_lookups.
+func IsComputedKind(kind string) bool {
+ _, ok := computedKinds[kind]
+ return ok
+}
+
+// computedLookup applies the named computed-kind transform to
+// canonical and returns the result with found=true. Unknown kinds
+// return ("", false).
+func computedLookup(kind, canonical string) (string, bool) {
+ switch kind {
+ case "lowercase":
+ return strings.ToLower(canonical), true
+ case "uppercase":
+ return strings.ToUpper(canonical), true
+ case "kebab-case":
+ return strings.ReplaceAll(strings.ToLower(canonical), " ", "-"), true
+ case "capitalize-first":
+ return capitalizeFirst(canonical), true
+ case "slug":
+ kebab := strings.ReplaceAll(strings.ToLower(canonical), " ", "-")
+ return nonSlugRune.ReplaceAllString(kebab, ""), true
+ }
+ return "", false
+}
+
+// capitalizeFirst returns canonical with the first rune uppercased
+// and the rest lowercased. Empty input returns empty output.
+func capitalizeFirst(canonical string) string {
+ if canonical == "" {
+ return ""
+ }
+ runes := []rune(canonical)
+ runes[0] = unicode.ToUpper(runes[0])
+ for i := 1; i < len(runes); i++ {
+ runes[i] = unicode.ToLower(runes[i])
+ }
+ return string(runes)
+}
+
+// Lookup returns the value mapped to the (kind, canonical) pair, or
+// ("", false) if no such mapping exists. Computed kinds short-circuit
+// the DB entirely.
+//
+// Canonical comparison is case-insensitive on the canonical side.
+// When multiple rows match the same (kind, canonical) pair, Lookup
+// returns the first one ordered by source priority
+// ('taught' > 'inferred' > 'synced' > 'seeded') then by created_at
+// ascending. For all-values access use LookupAll.
+func Lookup(db *sql.DB, kind, canonical string) (string, bool, error) {
+ if v, ok := computedLookup(kind, canonical); ok {
+ return v, true, nil
+ }
+ if db == nil {
+ return "", false, errors.New("lookups.Lookup: db is nil")
+ }
+ const q = `
+ SELECT value
+ FROM entity_lookups
+ WHERE kind = ? AND LOWER(canonical) = LOWER(?)
+ ORDER BY CASE source
+ WHEN 'taught' THEN 0
+ WHEN 'inferred' THEN 1
+ WHEN 'synced' THEN 2
+ WHEN 'seeded' THEN 3
+ ELSE 4
+ END ASC, created_at ASC
+ LIMIT 1
+ `
+ var value string
+ err := db.QueryRow(q, kind, canonical).Scan(&value)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", false, nil
+ }
+ if err != nil {
+ return "", false, fmt.Errorf("lookups.Lookup query: %w", err)
+ }
+ return value, true, nil
+}
+
+// LookupAll returns every value mapped to the (kind, canonical) pair,
+// deduplicated and ordered by source priority then created_at. For a
+// computed kind, the result has exactly one element. Returns an empty
+// slice on miss; never returns nil except on error.
+func LookupAll(db *sql.DB, kind, canonical string) ([]string, error) {
+ if v, ok := computedLookup(kind, canonical); ok {
+ return []string{v}, nil
+ }
+ if db == nil {
+ return nil, errors.New("lookups.LookupAll: db is nil")
+ }
+ const q = `
+ SELECT DISTINCT value
+ FROM entity_lookups
+ WHERE kind = ? AND LOWER(canonical) = LOWER(?)
+ ORDER BY CASE source
+ WHEN 'taught' THEN 0
+ WHEN 'inferred' THEN 1
+ WHEN 'synced' THEN 2
+ WHEN 'seeded' THEN 3
+ ELSE 4
+ END ASC, created_at ASC
+ `
+ rows, err := db.Query(q, kind, canonical)
+ if err != nil {
+ return nil, fmt.Errorf("lookups.LookupAll query: %w", err)
+ }
+ defer rows.Close()
+
+ values := make([]string, 0)
+ seen := make(map[string]struct{})
+ for rows.Next() {
+ var v string
+ if err := rows.Scan(&v); err != nil {
+ return nil, fmt.Errorf("lookups.LookupAll scan: %w", err)
+ }
+ if _, dup := seen[v]; dup {
+ continue
+ }
+ seen[v] = struct{}{}
+ values = append(values, v)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("lookups.LookupAll rows: %w", err)
+ }
+ return values, nil
+}
+
+// Upsert inserts or no-ops a single (kind, canonical, value) row with
+// the given source. Idempotent via the PRIMARY KEY (kind, canonical,
+// value). Different values for the same (kind, canonical) DO create
+// separate rows.
+//
+// Computed kinds cannot be Upsert'd. Source defaults to "taught"
+// when empty.
+func Upsert(db *sql.DB, kind, canonical, value, source string) error {
+ if IsComputedKind(kind) {
+ return fmt.Errorf("lookups.Upsert: %q is a computed kind, not table-backed", kind)
+ }
+ if db == nil {
+ return errors.New("lookups.Upsert: db is nil")
+ }
+ if strings.TrimSpace(kind) == "" {
+ return errors.New("lookups.Upsert: kind is required")
+ }
+ if strings.TrimSpace(canonical) == "" {
+ return errors.New("lookups.Upsert: canonical is required")
+ }
+ if strings.TrimSpace(value) == "" {
+ return errors.New("lookups.Upsert: value is required")
+ }
+ if source == "" {
+ source = SourceTaught
+ }
+ _, err := db.Exec(`
+ INSERT OR IGNORE INTO entity_lookups (kind, canonical, value, source)
+ VALUES (?, ?, ?, ?)
+ `, kind, canonical, value, source)
+ if err != nil {
+ return fmt.Errorf("lookups.Upsert exec: %w", err)
+ }
+ return nil
+}
+
+// SeedBatch inserts every row in seeds via INSERT OR IGNORE inside a
+// caller-supplied transaction. Returns the count of rows actually
+// inserted.
+func SeedBatch(tx *sql.Tx, seeds []LookupRow) (int, error) {
+ if tx == nil {
+ return 0, errors.New("lookups.SeedBatch: tx is nil")
+ }
+ stmt, err := tx.Prepare(`
+ INSERT OR IGNORE INTO entity_lookups (kind, canonical, value, source)
+ VALUES (?, ?, ?, ?)
+ `)
+ if err != nil {
+ return 0, fmt.Errorf("prepare seed insert: %w", err)
+ }
+ defer stmt.Close()
+
+ var inserted int
+ for _, r := range seeds {
+ source := r.Source
+ if source == "" {
+ source = SourceSeeded
+ }
+ res, err := stmt.Exec(r.Kind, r.Canonical, r.Value, source)
+ if err != nil {
+ return inserted, fmt.Errorf("insert seed (%s, %s, %s): %w", r.Kind, r.Canonical, r.Value, err)
+ }
+ n, err := res.RowsAffected()
+ if err == nil {
+ inserted += int(n)
+ }
+ }
+ return inserted, nil
+}
+
+// recallMissesTable captures entities the recall path could not
+// resolve through entity_lookups. RecordMisses creates it lazily on
+// first write, so no schema migration carries it; RefreshFromSynced
+// treats a missing table as "no misses recorded".
+const recallMissesTable = "learn_recall_misses"
+
+// createRecallMissesSQL is the lazy DDL for recallMissesTable. Entity
+// is stored lowercased so the scanner's case-insensitive matching
+// needs no per-row folding on read.
+const createRecallMissesSQL = `CREATE TABLE IF NOT EXISTS ` + recallMissesTable + ` (
+ entity TEXT PRIMARY KEY,
+ miss_count INTEGER NOT NULL DEFAULT 1,
+ first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+)`
+
+// RecordMisses upserts the lowercased form of each entity into the
+// recall-miss capture table, bumping miss_count and last_seen_at on
+// repeats. Telemetry-class: the recall hot path calls it best-effort
+// and ignores the error. Local-only — a single SQLite handle, never
+// the network.
+func RecordMisses(ctx context.Context, db *sql.DB, entities []string) error {
+ if db == nil {
+ return errors.New("lookups.RecordMisses: db is nil")
+ }
+ if len(entities) == 0 {
+ return nil
+ }
+ if _, err := db.ExecContext(ctx, createRecallMissesSQL); err != nil {
+ return fmt.Errorf("lookups.RecordMisses create: %w", err)
+ }
+ for _, e := range entities {
+ key := strings.ToLower(strings.TrimSpace(e))
+ if key == "" {
+ continue
+ }
+ if _, err := db.ExecContext(ctx, `INSERT INTO `+recallMissesTable+` (entity) VALUES (?)
+ ON CONFLICT(entity) DO UPDATE SET miss_count = miss_count + 1, last_seen_at = CURRENT_TIMESTAMP`,
+ key); err != nil {
+ return fmt.Errorf("lookups.RecordMisses insert %q: %w", key, err)
+ }
+ }
+ return nil
+}
+
+// DefaultSyncedPerKindCap bounds how many source='synced' rows
+// RefreshFromSynced will hold per kind (kind = resource_type),
+// counting rows from previous refreshes. The cap is a noise guard: a
+// huge resources table must not flood entity_lookups and dilute the
+// resolver.
+const DefaultSyncedPerKindCap = 200
+
+// RefreshOpts tunes RefreshFromSynced.
+type RefreshOpts struct {
+ // Extract derives entity tokens from one stored resource row's
+ // JSON payload. The caller composes it (the sync command wraps
+ // learn.ResourceEntitiesFromJSON with the per-CLI entity config)
+ // so this package stays free of the extraction layer. Nil makes
+ // the refresh a no-op.
+ Extract func(resourceType string, data []byte) []string
+ // PerKindCap overrides DefaultSyncedPerKindCap when > 0.
+ PerKindCap int
+}
+
+// RefreshResult reports what a RefreshFromSynced pass did.
+type RefreshResult struct {
+ Scanned int // resource rows walked
+ Inserted int // entity_lookups rows actually inserted (source='synced')
+}
+
+// RefreshFromSynced derives entity_lookups rows (source='synced') from
+// the already-synced local `resources` table, so entities absent from
+// print-time seeds become resolvable at runtime without a reprint. Its
+// only inputs are the local SQLite handle and a pure extraction
+// function — by construction it performs no network I/O.
+//
+// Noise guards, in order:
+// - only entities recorded as recall lookup misses are eligible
+// (exact or substring match against the recorded lowercased set,
+// mirroring ClassifyEntityMatch's permissive containment);
+// - entities already resolvable via ANY existing row (canonical or
+// value side, any source) are skipped;
+// - at most PerKindCap synced rows exist per kind afterward.
+//
+// Derived rows are self-canonical — (kind=resource_type,
+// canonical=entity, value=entity) — which is exactly what the recall
+// resolver needs to promote and resolve the entity. INSERT OR IGNORE
+// keeps repeat passes idempotent.
+func RefreshFromSynced(ctx context.Context, db *sql.DB, opts RefreshOpts) (RefreshResult, error) {
+ var res RefreshResult
+ if db == nil {
+ return res, errors.New("lookups.RefreshFromSynced: db is nil")
+ }
+ if opts.Extract == nil {
+ return res, nil
+ }
+ perKindCap := opts.PerKindCap
+ if perKindCap <= 0 {
+ perKindCap = DefaultSyncedPerKindCap
+ }
+
+ missed, err := loadRecallMisses(ctx, db)
+ if err != nil {
+ return res, err
+ }
+ if len(missed) == 0 {
+ return res, nil
+ }
+ resolvable, err := loadResolvableForms(ctx, db)
+ if err != nil {
+ return res, err
+ }
+ syncedCounts, err := loadSyncedKindCounts(ctx, db)
+ if err != nil {
+ return res, err
+ }
+
+ // Phase 1: walk resources and collect candidate rows. Collecting
+ // first keeps the read cursor free of interleaved writes.
+ candidates, scanned, err := collectSyncedCandidates(ctx, db, opts.Extract, missed, resolvable, syncedCounts, perKindCap)
+ if err != nil {
+ return res, err
+ }
+ res.Scanned = scanned
+
+ // Phase 2: insert. INSERT OR IGNORE makes a lost race with another
+ // process a silent no-op rather than an error.
+ for _, c := range candidates {
+ r, err := db.ExecContext(ctx,
+ `INSERT OR IGNORE INTO entity_lookups (kind, canonical, value, source) VALUES (?, ?, ?, ?)`,
+ c.Kind, c.Canonical, c.Value, SourceSynced)
+ if err != nil {
+ return res, fmt.Errorf("lookups.RefreshFromSynced insert (%s, %s): %w", c.Kind, c.Canonical, err)
+ }
+ if n, err := r.RowsAffected(); err == nil {
+ res.Inserted += int(n)
+ }
+ }
+ return res, nil
+}
+
+// collectSyncedCandidates walks the resources table and returns the
+// LookupRows RefreshFromSynced should insert, respecting the miss
+// filter, the already-resolvable dedup set, and the per-kind cap.
+// Mutates resolvable and syncedCounts as it tentatively claims slots
+// so a candidate seen twice in one pass counts once.
+func collectSyncedCandidates(ctx context.Context, db *sql.DB, extract func(string, []byte) []string, missed map[string]struct{}, resolvable map[string]struct{}, syncedCounts map[string]int, perKindCap int) ([]LookupRow, int, error) {
+ rows, err := db.QueryContext(ctx, `SELECT resource_type, data FROM resources ORDER BY resource_type, id`)
+ if err != nil {
+ // A store that has never synced has no resources table yet;
+ // there is simply nothing to scan.
+ if strings.Contains(err.Error(), "no such table") {
+ return nil, 0, nil
+ }
+ return nil, 0, fmt.Errorf("lookups.RefreshFromSynced query resources: %w", err)
+ }
+ defer rows.Close()
+
+ var out []LookupRow
+ scanned := 0
+ for rows.Next() {
+ var resourceType string
+ var data []byte
+ if err := rows.Scan(&resourceType, &data); err != nil {
+ return nil, scanned, fmt.Errorf("lookups.RefreshFromSynced scan resource: %w", err)
+ }
+ scanned++
+ if syncedCounts[resourceType] >= perKindCap {
+ continue
+ }
+ for _, ent := range extract(resourceType, data) {
+ ent = strings.TrimSpace(ent)
+ key := strings.ToLower(ent)
+ if key == "" {
+ continue
+ }
+ if !matchesRecordedMiss(key, missed) {
+ continue
+ }
+ if _, ok := resolvable[key]; ok {
+ continue
+ }
+ if syncedCounts[resourceType] >= perKindCap {
+ break
+ }
+ out = append(out, LookupRow{Kind: resourceType, Canonical: ent, Value: ent, Source: SourceSynced})
+ resolvable[key] = struct{}{}
+ syncedCounts[resourceType]++
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, scanned, fmt.Errorf("lookups.RefreshFromSynced rows: %w", err)
+ }
+ return out, scanned, nil
+}
+
+// matchesRecordedMiss reports whether a lowercased resource-side
+// entity corresponds to a recorded recall miss. Containment is
+// permissive in both directions for the same reason
+// ClassifyEntityMatch's is: resource titles carry compound entities a
+// query names only partially.
+func matchesRecordedMiss(key string, missed map[string]struct{}) bool {
+ if _, ok := missed[key]; ok {
+ return true
+ }
+ for m := range missed {
+ if strings.Contains(key, m) || strings.Contains(m, key) {
+ return true
+ }
+ }
+ return false
+}
+
+// loadRecallMisses returns the lowercased recorded-miss set. A missing
+// table means recall has never missed: an empty set, not an error.
+func loadRecallMisses(ctx context.Context, db *sql.DB) (map[string]struct{}, error) {
+ rows, err := db.QueryContext(ctx, `SELECT entity FROM `+recallMissesTable)
+ if err != nil {
+ if strings.Contains(err.Error(), "no such table") {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("lookups.RefreshFromSynced query misses: %w", err)
+ }
+ defer rows.Close()
+ out := make(map[string]struct{})
+ for rows.Next() {
+ var e string
+ if err := rows.Scan(&e); err != nil {
+ return nil, fmt.Errorf("lookups.RefreshFromSynced scan miss: %w", err)
+ }
+ if e = strings.ToLower(strings.TrimSpace(e)); e != "" {
+ out[e] = struct{}{}
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("lookups.RefreshFromSynced miss rows: %w", err)
+ }
+ return out, nil
+}
+
+// loadResolvableForms returns every lowercased canonical AND value
+// currently in entity_lookups, the dedup set that keeps the scanner
+// from re-deriving entities the resolver can already resolve.
+func loadResolvableForms(ctx context.Context, db *sql.DB) (map[string]struct{}, error) {
+ rows, err := db.QueryContext(ctx,
+ `SELECT LOWER(canonical) FROM entity_lookups UNION SELECT LOWER(value) FROM entity_lookups`)
+ if err != nil {
+ return nil, fmt.Errorf("lookups.RefreshFromSynced query existing: %w", err)
+ }
+ defer rows.Close()
+ out := make(map[string]struct{})
+ for rows.Next() {
+ var f string
+ if err := rows.Scan(&f); err != nil {
+ return nil, fmt.Errorf("lookups.RefreshFromSynced scan existing: %w", err)
+ }
+ out[f] = struct{}{}
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("lookups.RefreshFromSynced existing rows: %w", err)
+ }
+ return out, nil
+}
+
+// loadSyncedKindCounts returns the current number of synced rows per
+// kind so per-kind caps hold across repeated refresh passes, not just
+// within one.
+func loadSyncedKindCounts(ctx context.Context, db *sql.DB) (map[string]int, error) {
+ rows, err := db.QueryContext(ctx,
+ `SELECT kind, COUNT(*) FROM entity_lookups WHERE source = ? GROUP BY kind`, SourceSynced)
+ if err != nil {
+ return nil, fmt.Errorf("lookups.RefreshFromSynced query synced counts: %w", err)
+ }
+ defer rows.Close()
+ out := make(map[string]int)
+ for rows.Next() {
+ var kind string
+ var n int
+ if err := rows.Scan(&kind, &n); err != nil {
+ return nil, fmt.Errorf("lookups.RefreshFromSynced scan synced count: %w", err)
+ }
+ out[kind] = n
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("lookups.RefreshFromSynced synced count rows: %w", err)
+ }
+ return out, nil
+}
diff --git a/library/productivity/human-goat/internal/learn/lookups/store_test.go b/library/productivity/human-goat/internal/learn/lookups/store_test.go
new file mode 100644
index 0000000000..4134cdc28c
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/lookups/store_test.go
@@ -0,0 +1,694 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package lookups
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+// openTestDB creates a fresh empty SQLite database with the canonical
+// entity_lookups table.
+func openTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "lookups.db")
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open test db: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := db.Exec(`
+ CREATE TABLE entity_lookups (
+ kind TEXT NOT NULL,
+ canonical TEXT NOT NULL,
+ value TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'seeded',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (kind, canonical, value)
+ )
+ `); err != nil {
+ t.Fatalf("create entity_lookups: %v", err)
+ }
+ return db
+}
+
+func TestIsComputedKind(t *testing.T) {
+ t.Parallel()
+ for _, kind := range []string{"lowercase", "uppercase", "kebab-case", "capitalize-first", "slug"} {
+ if !IsComputedKind(kind) {
+ t.Errorf("IsComputedKind(%q) = false, want true", kind)
+ }
+ }
+ for _, kind := range []string{"country_code", "team_abbrev", "unknown-kind", ""} {
+ if IsComputedKind(kind) {
+ t.Errorf("IsComputedKind(%q) = true, want false", kind)
+ }
+ }
+}
+
+func TestLookup_ComputedKindsBypassDB(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ kind, in, want string
+ }{
+ {"lowercase", "Alpha", "alpha"},
+ {"uppercase", "alpha", "ALPHA"},
+ {"kebab-case", "New Thing", "new-thing"},
+ {"capitalize-first", "alpha", "Alpha"},
+ {"capitalize-first", "ALPHA", "Alpha"},
+ {"capitalize-first", "", ""},
+ {"slug", "New Thing", "new-thing"},
+ {"slug", "Cafe d'Or", "cafe-dor"},
+ }
+ for _, tc := range cases {
+ got, ok, err := Lookup(nil, tc.kind, tc.in)
+ if err != nil {
+ t.Errorf("Lookup(nil, %q, %q): %v", tc.kind, tc.in, err)
+ continue
+ }
+ if !ok {
+ t.Errorf("Lookup(nil, %q, %q): found=false", tc.kind, tc.in)
+ continue
+ }
+ if got != tc.want {
+ t.Errorf("Lookup(nil, %q, %q) = %q, want %q", tc.kind, tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestLookup_TableBackedRoundTrip(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ if err := Upsert(db, "team_code", "Alpha", "ALP", "seeded"); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ got, ok, err := Lookup(db, "team_code", "Alpha")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if !ok || got != "ALP" {
+ t.Errorf("Lookup = (%q, %v), want (\"ALP\", true)", got, ok)
+ }
+ got, ok, err = Lookup(db, "team_code", "alpha")
+ if err != nil {
+ t.Fatalf("Lookup lowercase: %v", err)
+ }
+ if !ok || got != "ALP" {
+ t.Errorf("Lookup lowercase = (%q, %v), want (\"ALP\", true)", got, ok)
+ }
+}
+
+func TestLookup_NotFound(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ got, ok, err := Lookup(db, "team_code", "Missing")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if ok || got != "" {
+ t.Errorf("Lookup miss = (%q, %v), want empty/false", got, ok)
+ }
+}
+
+func TestUpsert_IdempotentOnSameTriple(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ for i := 0; i < 3; i++ {
+ if err := Upsert(db, "team_code", "Alpha", "ALP", "taught"); err != nil {
+ t.Fatalf("Upsert iter %d: %v", i, err)
+ }
+ }
+ var count int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM entity_lookups WHERE kind = ? AND canonical = ?`,
+ "team_code", "Alpha").Scan(&count); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("Upsert 3 times: got %d rows, want 1", count)
+ }
+}
+
+func TestUpsert_DifferentValuesForSameCanonical(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ if err := Upsert(db, "team_code", "Alpha", "AL", "seeded"); err != nil {
+ t.Fatalf("Upsert AL: %v", err)
+ }
+ if err := Upsert(db, "team_code", "Alpha", "ALP", "seeded"); err != nil {
+ t.Fatalf("Upsert ALP: %v", err)
+ }
+ values, err := LookupAll(db, "team_code", "Alpha")
+ if err != nil {
+ t.Fatalf("LookupAll: %v", err)
+ }
+ if len(values) != 2 {
+ t.Errorf("LookupAll returned %d values, want 2: %v", len(values), values)
+ }
+}
+
+func TestLookupAll_EmptyOnMiss(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ values, err := LookupAll(db, "team_code", "Missing")
+ if err != nil {
+ t.Fatalf("LookupAll: %v", err)
+ }
+ if values == nil || len(values) != 0 {
+ t.Errorf("LookupAll miss: want non-nil empty slice, got %v", values)
+ }
+}
+
+func TestLookupAll_ComputedKind(t *testing.T) {
+ t.Parallel()
+ values, err := LookupAll(nil, "lowercase", "Alpha")
+ if err != nil {
+ t.Fatalf("LookupAll lowercase: %v", err)
+ }
+ if len(values) != 1 || values[0] != "alpha" {
+ t.Errorf("LookupAll(lowercase, Alpha) = %v, want [alpha]", values)
+ }
+}
+
+func TestLookup_SourcePriority(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ if err := Upsert(db, "team_code", "Alpha", "ALP", "seeded"); err != nil {
+ t.Fatalf("Upsert seeded: %v", err)
+ }
+ if err := Upsert(db, "team_code", "Alpha", "ALPH", "taught"); err != nil {
+ t.Fatalf("Upsert taught: %v", err)
+ }
+ got, _, err := Lookup(db, "team_code", "Alpha")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if got != "ALPH" {
+ t.Errorf("Lookup returned %q, want %q (taught should outrank seeded)", got, "ALPH")
+ }
+}
+
+// TestSourcePriorityLadder_BothReadPaths pins the full four-tier
+// ladder (taught > inferred > synced > seeded) on BOTH hardcoded CASE
+// sites. The two functions carry independent copies of the SQL;
+// updating only one silently inverts priority for the other, so every
+// combination is asserted against Lookup AND LookupAll.
+func TestSourcePriorityLadder_BothReadPaths(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ sources map[string]string // source -> value
+ want []string // LookupAll order; want[0] is the Lookup winner
+ }{
+ {
+ name: "taught beats synced",
+ sources: map[string]string{
+ SourceTaught: "TAUGHT",
+ SourceSynced: "SYNC",
+ },
+ want: []string{"TAUGHT", "SYNC"},
+ },
+ {
+ name: "synced beats seeded",
+ sources: map[string]string{
+ SourceSynced: "SYNC",
+ SourceSeeded: "SEED",
+ },
+ want: []string{"SYNC", "SEED"},
+ },
+ {
+ name: "full ladder",
+ sources: map[string]string{
+ SourceTaught: "TAUGHT",
+ SourceInferred: "INF",
+ SourceSynced: "SYNC",
+ SourceSeeded: "SEED",
+ },
+ want: []string{"TAUGHT", "INF", "SYNC", "SEED"},
+ },
+ }
+ for i, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ kind := fmt.Sprintf("kind_%d", i)
+ for source, value := range tc.sources {
+ if err := Upsert(db, kind, "Alpha", value, source); err != nil {
+ t.Fatalf("Upsert %s: %v", source, err)
+ }
+ }
+ got, ok, err := Lookup(db, kind, "Alpha")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if !ok || got != tc.want[0] {
+ t.Errorf("Lookup = (%q, %v), want (%q, true)", got, ok, tc.want[0])
+ }
+ all, err := LookupAll(db, kind, "Alpha")
+ if err != nil {
+ t.Fatalf("LookupAll: %v", err)
+ }
+ if len(all) != len(tc.want) {
+ t.Fatalf("LookupAll returned %v, want %v", all, tc.want)
+ }
+ for j := range tc.want {
+ if all[j] != tc.want[j] {
+ t.Errorf("LookupAll[%d] = %q, want %q (full order %v vs %v)", j, all[j], tc.want[j], all, tc.want)
+ }
+ }
+ })
+ }
+}
+
+func TestUpsert_NewKindWorksWithoutCodeChange(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ if err := Upsert(db, "stock_ticker", "Apple Inc.", "AAPL", "taught"); err != nil {
+ t.Fatalf("Upsert custom kind: %v", err)
+ }
+ got, ok, err := Lookup(db, "stock_ticker", "Apple Inc.")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if !ok || got != "AAPL" {
+ t.Errorf("Lookup = (%q, %v), want (AAPL, true)", got, ok)
+ }
+}
+
+func TestUpsert_ValidationErrors(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ cases := []struct {
+ name string
+ kind, canonical, value string
+ expectErrSubstr string
+ }{
+ {"empty kind", "", "Alpha", "ALP", "kind is required"},
+ {"whitespace kind", " ", "Alpha", "ALP", "kind is required"},
+ {"empty canonical", "team_code", "", "ALP", "canonical is required"},
+ {"empty value", "team_code", "Alpha", "", "value is required"},
+ {"computed kind", "lowercase", "Alpha", "alpha", "computed kind"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := Upsert(db, tc.kind, tc.canonical, tc.value, "taught")
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", tc.expectErrSubstr)
+ }
+ if !strings.Contains(err.Error(), tc.expectErrSubstr) {
+ t.Errorf("error = %v, want substring %q", err, tc.expectErrSubstr)
+ }
+ })
+ }
+}
+
+func TestUpsert_DefaultSource(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ if err := Upsert(db, "team_code", "Alpha", "ALP", ""); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ var source string
+ if err := db.QueryRow(`SELECT source FROM entity_lookups WHERE kind = ? AND canonical = ?`,
+ "team_code", "Alpha").Scan(&source); err != nil {
+ t.Fatalf("select source: %v", err)
+ }
+ if source != "taught" {
+ t.Errorf("default source = %q, want taught", source)
+ }
+}
+
+func TestSeedBatch_InsertsAndIdempotent(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ seeds := []LookupRow{
+ {Kind: "team_code", Canonical: "Alpha", Value: "ALP", Source: "seeded"},
+ {Kind: "team_code", Canonical: "Bravo", Value: "BRV", Source: "seeded"},
+ {Kind: "team_long", Canonical: "Alpha", Value: "Alpha United", Source: "seeded"},
+ }
+ tx, err := db.Begin()
+ if err != nil {
+ t.Fatalf("begin: %v", err)
+ }
+ inserted, err := SeedBatch(tx, seeds)
+ if err != nil {
+ tx.Rollback()
+ t.Fatalf("SeedBatch: %v", err)
+ }
+ if err := tx.Commit(); err != nil {
+ t.Fatalf("commit: %v", err)
+ }
+ if inserted != 3 {
+ t.Errorf("first SeedBatch inserted = %d, want 3", inserted)
+ }
+
+ tx2, err := db.Begin()
+ if err != nil {
+ t.Fatalf("begin tx2: %v", err)
+ }
+ inserted2, err := SeedBatch(tx2, seeds)
+ if err != nil {
+ tx2.Rollback()
+ t.Fatalf("second SeedBatch: %v", err)
+ }
+ if err := tx2.Commit(); err != nil {
+ t.Fatalf("commit second: %v", err)
+ }
+ if inserted2 != 0 {
+ t.Errorf("second SeedBatch inserted = %d, want 0 (idempotent)", inserted2)
+ }
+
+ var total int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM entity_lookups`).Scan(&total); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if total != 3 {
+ t.Errorf("total rows = %d, want 3", total)
+ }
+}
+
+func TestSeedBatch_DefaultsSource(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ seeds := []LookupRow{
+ {Kind: "team_code", Canonical: "Alpha", Value: "ALP"},
+ }
+ tx, err := db.Begin()
+ if err != nil {
+ t.Fatalf("begin: %v", err)
+ }
+ if _, err := SeedBatch(tx, seeds); err != nil {
+ tx.Rollback()
+ t.Fatalf("SeedBatch: %v", err)
+ }
+ if err := tx.Commit(); err != nil {
+ t.Fatalf("commit: %v", err)
+ }
+ var source string
+ if err := db.QueryRow(`SELECT source FROM entity_lookups WHERE kind = ? AND canonical = ?`,
+ "team_code", "Alpha").Scan(&source); err != nil {
+ t.Fatalf("select: %v", err)
+ }
+ if source != "seeded" {
+ t.Errorf("default seed source = %q, want seeded", source)
+ }
+}
+
+// openRefreshTestDB extends openTestDB with the synced `resources`
+// table the RefreshFromSynced scanner reads. The learn_recall_misses
+// table is deliberately NOT created here — RecordMisses owns its lazy
+// DDL and the scanner must tolerate its absence.
+func openRefreshTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db := openTestDB(t)
+ if _, err := db.Exec(`
+ CREATE TABLE resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )
+ `); err != nil {
+ t.Fatalf("create resources: %v", err)
+ }
+ return db
+}
+
+func insertRefreshResource(t *testing.T, db *sql.DB, resourceType, id, title string) {
+ t.Helper()
+ payload, err := json.Marshal(map[string]string{"title": title})
+ if err != nil {
+ t.Fatalf("marshal resource: %v", err)
+ }
+ if _, err := db.Exec(`INSERT INTO resources (resource_type, id, data) VALUES (?, ?, ?)`,
+ resourceType, id, string(payload)); err != nil {
+ t.Fatalf("insert resource: %v", err)
+ }
+}
+
+// titleExtractor is the test stand-in for the sync command's
+// learn.ResourceEntitiesFromJSON composition: it reads the `title`
+// field of the stored JSON payload as a single entity token.
+func titleExtractor(_ string, data []byte) []string {
+ var obj struct {
+ Title string `json:"title"`
+ }
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return nil
+ }
+ title := strings.TrimSpace(obj.Title)
+ if title == "" {
+ return nil
+ }
+ return []string{title}
+}
+
+func recordMiss(t *testing.T, db *sql.DB, entities ...string) {
+ t.Helper()
+ if err := RecordMisses(context.Background(), db, entities); err != nil {
+ t.Fatalf("RecordMisses: %v", err)
+ }
+}
+
+func countSyncedRows(t *testing.T, db *sql.DB, kind string) int {
+ t.Helper()
+ var n int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM entity_lookups WHERE kind = ? AND source = ?`,
+ kind, SourceSynced).Scan(&n); err != nil {
+ t.Fatalf("count synced rows: %v", err)
+ }
+ return n
+}
+
+func TestRecordMisses_CreatesTableAndBumpsCount(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ recordMiss(t, db, "Zephyr Cup", " ", "")
+ recordMiss(t, db, "zephyr cup")
+
+ var entity string
+ var count int
+ if err := db.QueryRow(`SELECT entity, miss_count FROM learn_recall_misses`).Scan(&entity, &count); err != nil {
+ t.Fatalf("select miss: %v", err)
+ }
+ if entity != "zephyr cup" {
+ t.Errorf("recorded entity = %q, want lowercased %q", entity, "zephyr cup")
+ }
+ if count != 2 {
+ t.Errorf("miss_count = %d, want 2 (case-folded repeat bumps, not duplicates)", count)
+ }
+}
+
+// TestRefreshFromSynced_DerivesMissedEntityAndResolves is the
+// staleness-heal scenario: an entity absent from print-time seeds but
+// present in synced resources becomes resolvable after a refresh pass,
+// under source='synced'.
+func TestRefreshFromSynced_DerivesMissedEntityAndResolves(t *testing.T) {
+ t.Parallel()
+ db := openRefreshTestDB(t)
+
+ // Pre-refresh: the entity is unresolvable.
+ if _, ok, err := Lookup(db, "events", "Zephyr Cup"); err != nil || ok {
+ t.Fatalf("pre-refresh Lookup = (ok=%v, err=%v), want miss", ok, err)
+ }
+
+ recordMiss(t, db, "Zephyr Cup")
+ insertRefreshResource(t, db, "events", "e1", "Zephyr Cup")
+
+ res, err := RefreshFromSynced(context.Background(), db, RefreshOpts{Extract: titleExtractor})
+ if err != nil {
+ t.Fatalf("RefreshFromSynced: %v", err)
+ }
+ if res.Inserted != 1 {
+ t.Fatalf("Inserted = %d, want 1", res.Inserted)
+ }
+
+ var source string
+ if err := db.QueryRow(`SELECT source FROM entity_lookups WHERE kind = 'events' AND canonical = 'Zephyr Cup'`).Scan(&source); err != nil {
+ t.Fatalf("select derived row: %v", err)
+ }
+ if source != SourceSynced {
+ t.Errorf("derived row source = %q, want %q", source, SourceSynced)
+ }
+
+ got, ok, err := Lookup(db, "events", "Zephyr Cup")
+ if err != nil {
+ t.Fatalf("post-refresh Lookup: %v", err)
+ }
+ if !ok || got != "Zephyr Cup" {
+ t.Errorf("post-refresh Lookup = (%q, %v), want (\"Zephyr Cup\", true)", got, ok)
+ }
+
+ // Idempotent: a second pass derives nothing new.
+ res2, err := RefreshFromSynced(context.Background(), db, RefreshOpts{Extract: titleExtractor})
+ if err != nil {
+ t.Fatalf("second RefreshFromSynced: %v", err)
+ }
+ if res2.Inserted != 0 {
+ t.Errorf("second pass Inserted = %d, want 0", res2.Inserted)
+ }
+}
+
+// TestRefreshFromSynced_ContainmentMatchesCompoundEntity covers the
+// compound-title shape: the recorded miss is embedded in a longer
+// resource-side entity, which still qualifies for derivation.
+func TestRefreshFromSynced_ContainmentMatchesCompoundEntity(t *testing.T) {
+ t.Parallel()
+ db := openRefreshTestDB(t)
+ recordMiss(t, db, "zephyr cup")
+ insertRefreshResource(t, db, "events", "e1", "Zephyr Cup Final")
+
+ res, err := RefreshFromSynced(context.Background(), db, RefreshOpts{Extract: titleExtractor})
+ if err != nil {
+ t.Fatalf("RefreshFromSynced: %v", err)
+ }
+ if res.Inserted != 1 {
+ t.Fatalf("Inserted = %d, want 1", res.Inserted)
+ }
+ got, ok, err := Lookup(db, "events", "Zephyr Cup Final")
+ if err != nil || !ok || got != "Zephyr Cup Final" {
+ t.Errorf("Lookup = (%q, %v, %v), want compound entity derived", got, ok, err)
+ }
+}
+
+// TestRefreshFromSynced_IgnoresNeverMissedEntities: resources whose
+// entities never appeared in a recorded recall miss must not produce
+// rows — the miss filter is the primary noise guard.
+func TestRefreshFromSynced_IgnoresNeverMissedEntities(t *testing.T) {
+ t.Parallel()
+ db := openRefreshTestDB(t)
+ recordMiss(t, db, "zephyr cup")
+ insertRefreshResource(t, db, "events", "e1", "Quiet Unrelated Item")
+
+ res, err := RefreshFromSynced(context.Background(), db, RefreshOpts{Extract: titleExtractor})
+ if err != nil {
+ t.Fatalf("RefreshFromSynced: %v", err)
+ }
+ if res.Inserted != 0 {
+ t.Errorf("Inserted = %d, want 0 (entity never missed)", res.Inserted)
+ }
+ if n := countSyncedRows(t, db, "events"); n != 0 {
+ t.Errorf("synced rows = %d, want 0", n)
+ }
+}
+
+// TestRefreshFromSynced_DedupsAgainstExistingCanonicals: an entity the
+// resolver can already resolve (existing canonical or value under any
+// source) is never re-derived.
+func TestRefreshFromSynced_DedupsAgainstExistingCanonicals(t *testing.T) {
+ t.Parallel()
+ db := openRefreshTestDB(t)
+ if err := Upsert(db, "events", "Zephyr Cup", "zc-2026", SourceSeeded); err != nil {
+ t.Fatalf("Upsert seeded: %v", err)
+ }
+ recordMiss(t, db, "zephyr cup")
+ insertRefreshResource(t, db, "events", "e1", "Zephyr Cup")
+
+ res, err := RefreshFromSynced(context.Background(), db, RefreshOpts{Extract: titleExtractor})
+ if err != nil {
+ t.Fatalf("RefreshFromSynced: %v", err)
+ }
+ if res.Inserted != 0 {
+ t.Errorf("Inserted = %d, want 0 (already resolvable via seeded canonical)", res.Inserted)
+ }
+ if n := countSyncedRows(t, db, "events"); n != 0 {
+ t.Errorf("synced rows = %d, want 0", n)
+ }
+}
+
+// TestRefreshFromSynced_PerKindCapHolds: a large resources fixture
+// stays under the per-kind cap, and pre-existing synced rows count
+// toward it across passes.
+func TestRefreshFromSynced_PerKindCapHolds(t *testing.T) {
+ t.Parallel()
+ db := openRefreshTestDB(t)
+
+ // 3 pre-existing synced rows for the kind + 12 fresh candidates,
+ // cap 5 -> only 2 more may land.
+ for i := 0; i < 3; i++ {
+ if err := Upsert(db, "events", fmt.Sprintf("Prior Entity %02d", i), fmt.Sprintf("Prior Entity %02d", i), SourceSynced); err != nil {
+ t.Fatalf("Upsert prior synced: %v", err)
+ }
+ }
+ for i := 0; i < 12; i++ {
+ entity := fmt.Sprintf("Missed Entity %02d", i)
+ recordMiss(t, db, entity)
+ insertRefreshResource(t, db, "events", fmt.Sprintf("e%02d", i), entity)
+ }
+
+ res, err := RefreshFromSynced(context.Background(), db, RefreshOpts{Extract: titleExtractor, PerKindCap: 5})
+ if err != nil {
+ t.Fatalf("RefreshFromSynced: %v", err)
+ }
+ if res.Inserted != 2 {
+ t.Errorf("Inserted = %d, want 2 (cap 5 minus 3 pre-existing)", res.Inserted)
+ }
+ if n := countSyncedRows(t, db, "events"); n != 5 {
+ t.Errorf("synced rows = %d, want exactly the cap (5)", n)
+ }
+}
+
+// TestRefreshFromSynced_DefaultCapOnLargeFixture drives a fixture
+// larger than DefaultSyncedPerKindCap through a default-opts pass and
+// asserts the cap holds.
+func TestRefreshFromSynced_DefaultCapOnLargeFixture(t *testing.T) {
+ t.Parallel()
+ db := openRefreshTestDB(t)
+ total := DefaultSyncedPerKindCap + 5
+ missed := make([]string, 0, total)
+ for i := 0; i < total; i++ {
+ entity := fmt.Sprintf("Bulk Entity %03d", i)
+ missed = append(missed, entity)
+ insertRefreshResource(t, db, "events", fmt.Sprintf("e%03d", i), entity)
+ }
+ recordMiss(t, db, missed...)
+
+ res, err := RefreshFromSynced(context.Background(), db, RefreshOpts{Extract: titleExtractor})
+ if err != nil {
+ t.Fatalf("RefreshFromSynced: %v", err)
+ }
+ if res.Inserted != DefaultSyncedPerKindCap {
+ t.Errorf("Inserted = %d, want default cap %d", res.Inserted, DefaultSyncedPerKindCap)
+ }
+ if n := countSyncedRows(t, db, "events"); n != DefaultSyncedPerKindCap {
+ t.Errorf("synced rows = %d, want %d", n, DefaultSyncedPerKindCap)
+ }
+}
+
+// TestRefreshFromSynced_LocalOnlyNoWorkPaths pins the constructive
+// no-network contract: the scanner's inputs are the local DB handle
+// and a pure extraction func, and every quiet path (no misses table,
+// nil extractor) is a clean no-op rather than an error.
+func TestRefreshFromSynced_LocalOnlyNoWorkPaths(t *testing.T) {
+ t.Parallel()
+ db := openRefreshTestDB(t)
+ insertRefreshResource(t, db, "events", "e1", "Zephyr Cup")
+
+ // No learn_recall_misses table at all: nothing derived, no error.
+ res, err := RefreshFromSynced(context.Background(), db, RefreshOpts{Extract: titleExtractor})
+ if err != nil {
+ t.Fatalf("RefreshFromSynced without misses table: %v", err)
+ }
+ if res.Inserted != 0 || res.Scanned != 0 {
+ t.Errorf("no-misses pass = %+v, want zero work", res)
+ }
+
+ // Nil extractor: no-op by construction.
+ recordMiss(t, db, "zephyr cup")
+ res, err = RefreshFromSynced(context.Background(), db, RefreshOpts{})
+ if err != nil {
+ t.Fatalf("RefreshFromSynced with nil Extract: %v", err)
+ }
+ if res.Inserted != 0 || res.Scanned != 0 {
+ t.Errorf("nil-extract pass = %+v, want zero work", res)
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/match.go b/library/productivity/human-goat/internal/learn/match.go
new file mode 100644
index 0000000000..64f309d62d
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/match.go
@@ -0,0 +1,224 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+)
+
+// EntityMatch classifies how well a stored learning's resource-side
+// entities overlap with the query's entities. The recall layer uses
+// this to decide whether a Jaccard-positive row belongs in `results`
+// (exact / partial), in `mismatches` (debug-only surface so the LLM can
+// see why a high-Jaccard candidate was dropped), or stays in `results`
+// with an `unknown` warning so the LLM can decide whether to fetch the
+// resource directly anyway.
+const (
+ EntityMatchExact = "exact" // every query entity has a match on the resource side
+ EntityMatchPartial = "partial" // either side has no entities; categorical match only
+ EntityMatchMismatch = "mismatch" // both sides have entities AND no overlap
+ EntityMatchUnknown = "unknown" // resource not in local store; entities can't be derived
+)
+
+// Per-hit warning constants. Kept as string constants so the recall
+// envelope's `warnings` array stays a stable schema for the LLM.
+const (
+ WarningLowConfidence = "low_confidence"
+ WarningResourceNotInStore = "resource_not_in_store"
+ // WarningCrossAliasMatch flags a hit whose entity_match verdict was
+ // promoted from Mismatch to Exact because the query and stored row
+ // share a canonical via entity_lookups even though their literal
+ // entities differ.
+ WarningCrossAliasMatch = "cross_alias_match"
+)
+
+// Top-level recall envelope warnings.
+const (
+ TopWarningNoLearningsForQueryFamily = "no_learnings_for_query_family"
+ // WarningSimilarShapeDifferentEntity surfaces when stored rows match
+ // the query's structural shape but resolve to a different canonical.
+ // Emitted as `similar_shape_different_entity:` (one per
+ // alternative canonical) so the agent sees a similar-shape learning
+ // exists rather than treating the query as a cold start.
+ WarningSimilarShapeDifferentEntity = "similar_shape_different_entity"
+ // WarningAmbiguousAlias surfaces when a single query entity resolves
+ // to multiple canonicals via entity_lookups. Narrow trigger: only
+ // fires for a single entity with multi-canonical resolution; does not
+ // fire for multi-entity queries where each individual entity resolves
+ // unambiguously even if the union spans multiple canonicals.
+ WarningAmbiguousAlias = "ambiguous_alias"
+ // TopWarningLookupRefreshAvailable surfaces when a cold recall
+ // carries a query entity with no entity_lookups row under ANY
+ // source (taught / inferred / synced / seeded). Stateless: no
+ // candidate row or table write backs the warning itself — the text
+ // tells the agent to run `sync`, whose post-sync lookup-refresh
+ // scanner derives rows (source='synced') from already-synced local
+ // data for entities recorded as recall lookup misses. Emitted as
+ // "lookup_refresh_available: (run sync to refresh entity
+ // lookups)" — one per unresolvable entity — and disappears on its
+ // own once the entity resolves.
+ TopWarningLookupRefreshAvailable = "lookup_refresh_available"
+ // TopWarningCandidatesPresent surfaces exactly when the envelope's
+ // `candidates` array is non-empty: quarantined auto-captured
+ // candidates rode along for this query, each carrying a two-step
+ // byte-exact next_action (try the candidate, then
+ // `learnings confirm `). With nothing open the warning is
+ // absent AND the candidates key is omitted entirely, keeping the
+ // envelope byte-stable for stores with nothing pending.
+ TopWarningCandidatesPresent = "candidates_present"
+)
+
+// Jaccard returns the token-set Jaccard coefficient of two string
+// slices. Tokens are compared case-insensitively after trimming
+// whitespace. An empty slice on either side yields 0.0; identical
+// non-empty sets yield 1.0.
+func Jaccard(a, b []string) float64 {
+ if len(a) == 0 || len(b) == 0 {
+ return 0
+ }
+ setA := tokenSet(a)
+ setB := tokenSet(b)
+ if len(setA) == 0 || len(setB) == 0 {
+ return 0
+ }
+ inter := 0
+ for tok := range setA {
+ if _, ok := setB[tok]; ok {
+ inter++
+ }
+ }
+ union := len(setA) + len(setB) - inter
+ if union == 0 {
+ return 0
+ }
+ return float64(inter) / float64(union)
+}
+
+// JaccardTokens returns the same coefficient over space-separated
+// token strings, the form NonEntityNormalized lands in on the
+// search_learnings row.
+func JaccardTokens(a, b string) float64 {
+ return Jaccard(strings.Fields(a), strings.Fields(b))
+}
+
+// ClassifyEntityMatch returns the EntityMatch verdict for a query's
+// entities against a resource's entities.
+//
+// Rules:
+//
+// - both empty -> partial (no entity signal; categorical match)
+// - query empty, resource has entities -> partial
+// - query has entities, resource empty -> partial
+// - both non-empty AND any overlap -> exact
+// - both non-empty AND zero overlap -> mismatch
+//
+// Comparison is case-insensitive. Substring matching is deliberately
+// permissive on the resource side: resource entities come from titles
+// where a query entity may be embedded in a longer compound entity.
+func ClassifyEntityMatch(queryEntities, resourceEntities []string) string {
+ qEmpty := len(queryEntities) == 0
+ rEmpty := len(resourceEntities) == 0
+ if qEmpty && rEmpty {
+ return EntityMatchPartial
+ }
+ if qEmpty || rEmpty {
+ return EntityMatchPartial
+ }
+ for _, q := range queryEntities {
+ ql := strings.ToLower(strings.TrimSpace(q))
+ if ql == "" {
+ continue
+ }
+ for _, r := range resourceEntities {
+ rl := strings.ToLower(strings.TrimSpace(r))
+ if rl == "" {
+ continue
+ }
+ if ql == rl || strings.Contains(rl, ql) || strings.Contains(ql, rl) {
+ return EntityMatchExact
+ }
+ }
+ }
+ return EntityMatchMismatch
+}
+
+// ResourceEntitiesFromJSON pulls entity tokens from a stored resource's
+// JSON payload by reading the named fields and running them through
+// the entity extractor.
+//
+// The cfg parameter controls how tokens are classified (which patterns
+// are tickers, which stopwords apply); the same Config used on the
+// query side should be used here so the classification is symmetric.
+//
+// fields names which JSON keys to read in order; the first non-empty
+// value lands first in the joined extractor input. Empty payload, nil
+// fields, or a non-object payload all return nil.
+//
+// Returns both Entities and Tickers in a single flat slice — a query
+// for a ticker should match the resource that IS that ticker, even
+// though it's a ticker rather than a free-text entity.
+func ResourceEntitiesFromJSON(data []byte, fields []string, cfg *entities.Config) []string {
+ if len(data) == 0 || len(fields) == 0 {
+ return nil
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return nil
+ }
+ parts := make([]string, 0, len(fields))
+ for _, field := range fields {
+ raw, ok := obj[field]
+ if !ok {
+ continue
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err != nil {
+ continue
+ }
+ s = strings.TrimSpace(s)
+ if s != "" {
+ parts = append(parts, s)
+ }
+ }
+ if len(parts) == 0 {
+ return nil
+ }
+ parsed := entities.Extract(strings.Join(parts, " "), cfg)
+ out := make([]string, 0, len(parsed.Entities)+len(parsed.Tickers))
+ out = append(out, parsed.Entities...)
+ out = append(out, parsed.Tickers...)
+ return out
+}
+
+// ParseStoredEntities decodes the JSON array stored in
+// search_learnings.query_entities into a Go slice. Empty / "null" /
+// "[]" all return nil.
+func ParseStoredEntities(raw string) ([]string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "null" {
+ return nil, nil
+ }
+ var out []string
+ if err := json.Unmarshal([]byte(raw), &out); err != nil {
+ return nil, fmt.Errorf("parse stored entities: %w", err)
+ }
+ return out, nil
+}
+
+// tokenSet builds a lowercased deduped set from a slice.
+func tokenSet(in []string) map[string]struct{} {
+ out := make(map[string]struct{}, len(in))
+ for _, t := range in {
+ t = strings.ToLower(strings.TrimSpace(t))
+ if t == "" {
+ continue
+ }
+ out[t] = struct{}{}
+ }
+ return out
+}
diff --git a/library/productivity/human-goat/internal/learn/match_test.go b/library/productivity/human-goat/internal/learn/match_test.go
new file mode 100644
index 0000000000..28092b0e07
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/match_test.go
@@ -0,0 +1,187 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestClassifyEntityMatch_Exact(t *testing.T) {
+ t.Parallel()
+ got := ClassifyEntityMatch([]string{"Alpha"}, []string{"Alpha", "hosting"})
+ if got != EntityMatchExact {
+ t.Errorf("want exact, got %q", got)
+ }
+}
+
+func TestClassifyEntityMatch_PartialEmptyQuery(t *testing.T) {
+ t.Parallel()
+ got := ClassifyEntityMatch(nil, []string{"Alpha"})
+ if got != EntityMatchPartial {
+ t.Errorf("want partial, got %q", got)
+ }
+}
+
+func TestClassifyEntityMatch_PartialEmptyResource(t *testing.T) {
+ t.Parallel()
+ got := ClassifyEntityMatch([]string{"Alpha"}, nil)
+ if got != EntityMatchPartial {
+ t.Errorf("want partial, got %q", got)
+ }
+}
+
+func TestClassifyEntityMatch_Mismatch(t *testing.T) {
+ t.Parallel()
+ got := ClassifyEntityMatch([]string{"Alpha"}, []string{"Bravo"})
+ if got != EntityMatchMismatch {
+ t.Errorf("want mismatch, got %q", got)
+ }
+}
+
+func TestClassifyEntityMatch_BothEmpty(t *testing.T) {
+ t.Parallel()
+ got := ClassifyEntityMatch(nil, nil)
+ if got != EntityMatchPartial {
+ t.Errorf("want partial, got %q", got)
+ }
+}
+
+func TestClassifyEntityMatch_CaseInsensitive(t *testing.T) {
+ t.Parallel()
+ got := ClassifyEntityMatch([]string{"alpha"}, []string{"ALPHA"})
+ if got != EntityMatchExact {
+ t.Errorf("want exact, got %q", got)
+ }
+}
+
+func TestClassifyEntityMatch_SubstringPermissive(t *testing.T) {
+ t.Parallel()
+ got := ClassifyEntityMatch([]string{"Alpha"}, []string{"Alpha-Bravo-Charlie"})
+ if got != EntityMatchExact {
+ t.Errorf("substring match: want exact, got %q", got)
+ }
+}
+
+func TestJaccard_Identical(t *testing.T) {
+ t.Parallel()
+ got := Jaccard([]string{"a", "b"}, []string{"a", "b"})
+ if got != 1.0 {
+ t.Errorf("want 1.0, got %v", got)
+ }
+}
+
+func TestJaccard_Disjoint(t *testing.T) {
+ t.Parallel()
+ got := Jaccard([]string{"a", "b"}, []string{"c", "d"})
+ if got != 0.0 {
+ t.Errorf("want 0, got %v", got)
+ }
+}
+
+func TestJaccard_PartialOverlap(t *testing.T) {
+ t.Parallel()
+ got := Jaccard([]string{"a", "b", "c"}, []string{"b", "c", "d"})
+ if got < 0.499 || got > 0.501 {
+ t.Errorf("want ~0.5, got %v", got)
+ }
+}
+
+func TestJaccard_EmptySides(t *testing.T) {
+ t.Parallel()
+ if Jaccard(nil, []string{"a"}) != 0 {
+ t.Errorf("empty a should be 0")
+ }
+ if Jaccard([]string{"a"}, nil) != 0 {
+ t.Errorf("empty b should be 0")
+ }
+}
+
+func TestResourceEntitiesFromJSON_FieldsExtracted(t *testing.T) {
+ t.Parallel()
+ data, _ := json.Marshal(map[string]any{
+ "title": "Alpha widget 2026",
+ "subtitle": "Bravo",
+ "id": "TICKER-A123",
+ })
+ got := ResourceEntitiesFromJSON(data, []string{"title", "subtitle", "id"}, testConfig())
+ if len(got) == 0 {
+ t.Fatalf("got nothing; want at least Alpha/Bravo")
+ }
+ if !containsString(got, "Alpha") {
+ t.Errorf("missing Alpha; got %v", got)
+ }
+ if !containsString(got, "Bravo") {
+ t.Errorf("missing Bravo; got %v", got)
+ }
+}
+
+func TestResourceEntitiesFromJSON_EmptyInputs(t *testing.T) {
+ t.Parallel()
+ if got := ResourceEntitiesFromJSON(nil, []string{"title"}, testConfig()); got != nil {
+ t.Errorf("nil data: want nil, got %v", got)
+ }
+ if got := ResourceEntitiesFromJSON([]byte(`{"title":"X"}`), nil, testConfig()); got != nil {
+ t.Errorf("nil fields: want nil, got %v", got)
+ }
+ if got := ResourceEntitiesFromJSON([]byte(`{}`), []string{"missing"}, testConfig()); got != nil {
+ t.Errorf("missing fields: want nil, got %v", got)
+ }
+}
+
+func TestParseStoredEntities(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in string
+ want []string
+ }{
+ {"", nil},
+ {"null", nil},
+ {"[]", nil},
+ {`["Alpha"]`, []string{"Alpha"}},
+ {`["Alpha","Bravo"]`, []string{"Alpha", "Bravo"}},
+ }
+ for _, tc := range cases {
+ got, err := ParseStoredEntities(tc.in)
+ if err != nil {
+ t.Errorf("ParseStoredEntities(%q): %v", tc.in, err)
+ continue
+ }
+ if len(got) != len(tc.want) {
+ t.Errorf("ParseStoredEntities(%q) len = %d, want %d", tc.in, len(got), len(tc.want))
+ }
+ }
+}
+
+func containsString(haystack []string, needle string) bool {
+ for _, s := range haystack {
+ if s == needle {
+ return true
+ }
+ }
+ return false
+}
+
+// TestEnvelopeWarningCodesStable pins the top-level envelope warning
+// vocabulary. These codes are a published contract agents parse and
+// store guidance against; renaming one silently orphans that guidance.
+func TestEnvelopeWarningCodesStable(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ got string
+ want string
+ }{
+ {"no_learnings_for_query_family", TopWarningNoLearningsForQueryFamily, "no_learnings_for_query_family"},
+ {"similar_shape_different_entity", WarningSimilarShapeDifferentEntity, "similar_shape_different_entity"},
+ {"ambiguous_alias", WarningAmbiguousAlias, "ambiguous_alias"},
+ {"lookup_refresh_available", TopWarningLookupRefreshAvailable, "lookup_refresh_available"},
+ {"candidates_present", TopWarningCandidatesPresent, "candidates_present"},
+ }
+ for _, tc := range cases {
+ if tc.got != tc.want {
+ t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/normalize.go b/library/productivity/human-goat/internal/learn/normalize.go
new file mode 100644
index 0000000000..201fdcb812
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/normalize.go
@@ -0,0 +1,82 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+)
+
+// NormalizedQuery is the entity-aware normalized representation of a
+// query string. Replaces lowercase+stopword scalar normalization,
+// which destroyed entity tokens (a query about one entity could match
+// a stored learning about a different entity once entity names were
+// folded into the non-entity bag).
+//
+// - Original: the input as received, untouched.
+// - Entities: case-preserving identity-bearing tokens.
+// - Tickers: CLI-shape identifier tokens.
+// - NonEntityNormalized: space-joined, alphabetically-sorted,
+// lowercased non-entity content tokens. This is the stable key
+// for Jaccard comparison.
+type NormalizedQuery struct {
+ Original string
+ Entities []string
+ Tickers []string
+ NonEntityNormalized string
+}
+
+// Normalize parses a query into its entity-aware form. cfg controls the
+// CLI-specific ticker patterns, stopwords, and same-referent synonym
+// folds; pass nil to use the default Config (domain-agnostic stopwords
+// and synonyms, no ticker patterns).
+//
+// Synonym folding runs BEFORE extraction so same-referent phrasings
+// ("yesterday" / "last night") land in one query family, and so a
+// capitalized sentence-initial variant never reaches the entity
+// classifier. The store package applies the same folds inside
+// NormalizeQuery, keeping write (teach) and read (recall) symmetric —
+// mirroring how PromoteEntities is applied on both sides.
+//
+// The caller owns the Config — the recall and teach hot paths build it
+// once via NewConfig at CLI startup and pass it explicitly to every
+// Normalize call. Configs are not safe for concurrent mutation.
+func Normalize(query string, cfg *entities.Config) NormalizedQuery {
+ return normalize(query, cfg, true)
+}
+
+// NormalizeUnfolded parses a query WITHOUT applying synonym folds.
+// Sole intended consumer is the playbook dual-key lookup: its raw-key
+// fallback needs the family a pre-fold CLI build would have derived,
+// so rows stored before folding shipped stay reachable (and get
+// rekeyed in place on first hit). Everything else uses Normalize.
+func NormalizeUnfolded(query string, cfg *entities.Config) NormalizedQuery {
+ return normalize(query, cfg, false)
+}
+
+func normalize(query string, cfg *entities.Config, fold bool) NormalizedQuery {
+ if cfg == nil {
+ cfg = entities.NewConfig()
+ }
+ extracted := query
+ if fold {
+ extracted = cfg.FoldSynonyms(query)
+ }
+ result := entities.Extract(extracted, cfg)
+
+ // Sort non-entity tokens for stable Jaccard. Without this,
+ // "alpha bravo" and "bravo alpha" would normalize differently
+ // and the matcher would underdedupe.
+ tokens := append([]string(nil), result.NonEntityTokens...)
+ sort.Strings(tokens)
+
+ return NormalizedQuery{
+ Original: query,
+ Entities: append([]string(nil), result.Entities...),
+ Tickers: append([]string(nil), result.Tickers...),
+ NonEntityNormalized: strings.Join(tokens, " "),
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/normalize_test.go b/library/productivity/human-goat/internal/learn/normalize_test.go
new file mode 100644
index 0000000000..6a5adf8e48
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/normalize_test.go
@@ -0,0 +1,189 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "reflect"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+)
+
+func testConfig() *entities.Config {
+ cfg := entities.NewConfig()
+ cfg.RegisterTickerPattern(regexp.MustCompile(`^TICKER-[A-Z0-9]+(-[A-Z0-9]+)*$`))
+ cfg.RegisterTickerPattern(regexp.MustCompile(`^slug-[a-z0-9-]+$`))
+ cfg.RegisterStopwords("rate", "rates", "score", "scores")
+ return cfg
+}
+
+func TestNormalize_PreservesEntities(t *testing.T) {
+ t.Parallel()
+ got := Normalize("rate Alpha score world", testConfig())
+ want := NormalizedQuery{
+ Original: "rate Alpha score world",
+ Entities: []string{"Alpha"},
+ Tickers: nil,
+ NonEntityNormalized: "world",
+ }
+ if !reflect.DeepEqual(got.Entities, want.Entities) {
+ t.Errorf("Entities = %v, want %v", got.Entities, want.Entities)
+ }
+ if got.NonEntityNormalized != want.NonEntityNormalized {
+ t.Errorf("NonEntityNormalized = %q, want %q", got.NonEntityNormalized, want.NonEntityNormalized)
+ }
+ if got.Original != want.Original {
+ t.Errorf("Original = %q, want %q", got.Original, want.Original)
+ }
+}
+
+func TestNormalize_DifferentEntitiesSameNonEntity(t *testing.T) {
+ t.Parallel()
+ cfg := testConfig()
+ a := Normalize("rate Alpha score world", cfg)
+ b := Normalize("rate Bravo score world", cfg)
+ if a.NonEntityNormalized != b.NonEntityNormalized {
+ t.Fatalf("non-entity forms differ:\n a = %q\n b = %q",
+ a.NonEntityNormalized, b.NonEntityNormalized)
+ }
+ if reflect.DeepEqual(a.Entities, b.Entities) {
+ t.Fatalf("entities should differ; got identical = %v", a.Entities)
+ }
+}
+
+func TestNormalize_EmptyQuery(t *testing.T) {
+ t.Parallel()
+ for _, in := range []string{"", " ", "\n\t"} {
+ got := Normalize(in, testConfig())
+ if got.Original != in {
+ t.Errorf("Original = %q, want %q", got.Original, in)
+ }
+ if len(got.Entities) != 0 {
+ t.Errorf("Entities = %v, want empty for input %q", got.Entities, in)
+ }
+ if got.NonEntityNormalized != "" {
+ t.Errorf("NonEntityNormalized = %q, want empty for input %q", got.NonEntityNormalized, in)
+ }
+ }
+}
+
+func TestNormalize_TickerLanding(t *testing.T) {
+ t.Parallel()
+ got := Normalize("look up slug-thing-foo-456", testConfig())
+ want := []string{"slug-thing-foo-456"}
+ if !reflect.DeepEqual(got.Tickers, want) {
+ t.Errorf("Tickers = %v, want %v", got.Tickers, want)
+ }
+ if got.NonEntityNormalized != "look up" {
+ t.Errorf("NonEntityNormalized = %q, want %q", got.NonEntityNormalized, "look up")
+ }
+}
+
+func TestNormalize_NilConfigFallback(t *testing.T) {
+ t.Parallel()
+ got := Normalize("look up slug-thing-foo-456", nil)
+ if len(got.Tickers) != 0 {
+ t.Errorf("Tickers = %v, want empty for nil config", got.Tickers)
+ }
+ // Without ticker registration, the slug becomes a non-entity token.
+ want := "look slug-thing-foo-456 up"
+ if got.NonEntityNormalized != want {
+ t.Errorf("NonEntityNormalized = %q, want %q", got.NonEntityNormalized, want)
+ }
+}
+
+func TestNormalize_StableSort(t *testing.T) {
+ t.Parallel()
+ cfg := testConfig()
+ a := Normalize("world cup france", cfg)
+ b := Normalize("france world cup", cfg)
+ c := Normalize("cup france world", cfg)
+ // "France" is capitalized so it's an entity; the non-entity bag
+ // has "cup" + "world" only.
+ if a.NonEntityNormalized != b.NonEntityNormalized || b.NonEntityNormalized != c.NonEntityNormalized {
+ t.Errorf("order independence broken:\n a = %q\n b = %q\n c = %q",
+ a.NonEntityNormalized, b.NonEntityNormalized, c.NonEntityNormalized)
+ }
+}
+
+func TestNormalize_SynonymFoldsToOneFamily(t *testing.T) {
+ t.Parallel()
+ cfg := testConfig()
+ a := Normalize("why did Alpha win yesterday", cfg)
+ b := Normalize("why did Alpha win last night", cfg)
+ if a.NonEntityNormalized == "" {
+ t.Fatal("expected non-empty non-entity form")
+ }
+ if a.NonEntityNormalized != b.NonEntityNormalized {
+ t.Errorf("same-referent phrasings landed in different families:\n a = %q\n b = %q",
+ a.NonEntityNormalized, b.NonEntityNormalized)
+ }
+ if !reflect.DeepEqual(a.Entities, b.Entities) {
+ t.Errorf("entities diverged: %v vs %v", a.Entities, b.Entities)
+ }
+}
+
+func TestNormalize_SentenceInitialVariantNotAnEntity(t *testing.T) {
+ t.Parallel()
+ // Folding runs before extraction, so the capitalized "Last" in a
+ // sentence-initial "Last night" never reaches the entity classifier.
+ got := Normalize("Last night Alpha won", testConfig())
+ if !reflect.DeepEqual(got.Entities, []string{"Alpha"}) {
+ t.Errorf("Entities = %v, want [Alpha]", got.Entities)
+ }
+ if got.NonEntityNormalized != "won yesterday" {
+ t.Errorf("NonEntityNormalized = %q, want %q", got.NonEntityNormalized, "won yesterday")
+ }
+}
+
+func TestNormalize_TonightDoesNotFoldToYesterday(t *testing.T) {
+ t.Parallel()
+ got := Normalize("who wins tonight", testConfig())
+ if strings.Contains(got.NonEntityNormalized, "yesterday") {
+ t.Errorf("tonight crossed a day boundary: %q", got.NonEntityNormalized)
+ }
+ if !strings.Contains(got.NonEntityNormalized, "tonight") {
+ t.Errorf("tonight should survive normalization: %q", got.NonEntityNormalized)
+ }
+}
+
+func TestNormalize_RegisteredSynonymFoldsUndeclaredDoesNot(t *testing.T) {
+ t.Parallel()
+ cfg := testConfig()
+ cfg.RegisterSynonyms(map[string]string{"foo bar": "baz"})
+ declared := Normalize("check foo bar now", cfg)
+ canonical := Normalize("check baz now", cfg)
+ if declared.NonEntityNormalized != canonical.NonEntityNormalized {
+ t.Errorf("declared pair should share a family: %q vs %q",
+ declared.NonEntityNormalized, canonical.NonEntityNormalized)
+ }
+ undeclared := Normalize("check foo qux now", cfg)
+ if undeclared.NonEntityNormalized == canonical.NonEntityNormalized {
+ t.Errorf("undeclared pair must not fold: %q", undeclared.NonEntityNormalized)
+ }
+}
+
+func TestNormalizeUnfolded_SkipsSynonymFolding(t *testing.T) {
+ t.Parallel()
+ cfg := testConfig()
+ got := NormalizeUnfolded("why did Alpha win last night", cfg)
+ if got.NonEntityNormalized != "last night win" {
+ t.Errorf("NonEntityNormalized = %q, want %q (pre-fold form)",
+ got.NonEntityNormalized, "last night win")
+ }
+ if got.Original != "why did Alpha win last night" {
+ t.Errorf("Original = %q must stay untouched", got.Original)
+ }
+}
+
+func TestNormalize_OriginalPreservedThroughFold(t *testing.T) {
+ t.Parallel()
+ in := "why did Alpha win last night"
+ got := Normalize(in, testConfig())
+ if got.Original != in {
+ t.Errorf("Original = %q, want %q", got.Original, in)
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/patterns/apply.go b/library/productivity/human-goat/internal/learn/patterns/apply.go
new file mode 100644
index 0000000000..c2967c2b62
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/patterns/apply.go
@@ -0,0 +1,317 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package patterns
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/lookups"
+)
+
+// DefaultJaccardMin matches the recall-side floor. Applying the same
+// threshold for pattern matching keeps the merged result list
+// coherent: direct hits and pattern hits both pass through the same
+// non-entity Jaccard gate before substitution + verification.
+const DefaultJaccardMin = 0.6
+
+// Hit is the pattern-side analogue of recall.Hit. Apply returns one
+// Hit per substituted-and-verified candidate. The CLI's recall
+// integration translates these into recall.Hit values before merging.
+//
+// We don't import recall.Hit directly because that would create an
+// import cycle (recall.go calls into patterns.Apply). The translation
+// is cheap and lossy in a useful direction: the recall layer adds its
+// own per-hit metadata (warnings, last_observed_at lookups against the
+// learning row) that the pattern layer doesn't have.
+type Hit struct {
+ ResourceID string
+ ResourceType string
+ Venue string
+ Confidence int
+ MatchScore float64
+ EntityMatch string
+ ResourceEntities []string
+ Source string
+ PatternID int64
+ LastObservedAt *time.Time
+ // Meta carries structured diagnostic reasons when a pattern matched
+ // textually but failed verification (substitution miss, resource
+ // not in store, etc.). Empty on success.
+ Meta map[string]string
+}
+
+// Opts tunes Apply behavior. Zero-value defaults:
+//
+// JaccardMin -> DefaultJaccardMin (0.6)
+// Limit -> 10
+// NoVerify -> false (verify candidates against resources table)
+//
+// AdditionalKinds is the CLI-supplied list of table-backed lookup
+// kinds (e.g., from LearnConfig.EntityLookupSeeds) the pattern engine
+// should try ahead of the built-in computed kinds when a template's
+// entity_kind doesn't have a direct slot in the template string.
+type Opts struct {
+ JaccardMin float64
+ Limit int
+ NoVerify bool
+ AdditionalKinds []string
+}
+
+// Apply walks search_patterns, finds patterns whose query_template
+// matches the live query (via the same non-entity normalized form +
+// Jaccard threshold the direct recall path uses), substitutes the live
+// query's entity via lookups.Lookup, and verifies each substituted
+// candidate exists in the resources table (or matches a prefix LIKE
+// search for the prefix strategy). Returns the verified hits.
+//
+// queryEntities is the case-preserving entity slice extracted from the
+// live query by the caller (typically via learn.Normalize). The caller
+// passes it in to avoid re-running the extractor: Apply is invoked
+// from recall.Recall which already computed entities once.
+//
+// nonEntityNormalized is the sorted lowercased non-entity token string
+// the recall path computes. It's the same key
+// search_patterns.query_template stores (after extraction templated
+// the entity slot out).
+//
+// db must be non-nil. ctx is honored on every db.Query call so a
+// caller can bound the apply pass alongside the recall query itself.
+func Apply(ctx context.Context, db *sql.DB, query, nonEntityNormalized string, queryEntities []string, opts Opts) ([]Hit, error) {
+ if db == nil {
+ return nil, errors.New("patterns.Apply: db is nil")
+ }
+ if len(queryEntities) == 0 {
+ return nil, nil
+ }
+
+ jMin := opts.JaccardMin
+ if jMin == 0 {
+ jMin = DefaultJaccardMin
+ }
+ if jMin < 0 {
+ jMin = 0
+ }
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 10
+ }
+
+ rows, err := db.QueryContext(ctx, `SELECT id, query_template, resource_template, resource_type,
+ COALESCE(venue, ''), strategy, entity_kind, confidence, source, created_at, last_observed_at
+ FROM search_patterns`)
+ if err != nil {
+ return nil, fmt.Errorf("patterns.Apply query: %w", err)
+ }
+ defer rows.Close()
+
+ queryTokens := strings.Fields(nonEntityNormalized)
+ hits := make([]Hit, 0)
+
+ allKinds := make([]string, 0, len(opts.AdditionalKinds)+len(candidateKinds))
+ allKinds = append(allKinds, opts.AdditionalKinds...)
+ allKinds = append(allKinds, candidateKinds...)
+
+ for rows.Next() {
+ var (
+ id int64
+ queryTemplate string
+ resourceTmpl string
+ resourceType string
+ venue string
+ strategy string
+ entityKind string
+ confidence int
+ source string
+ createdAt time.Time
+ lastObserved sql.NullTime
+ )
+ if err := rows.Scan(&id, &queryTemplate, &resourceTmpl, &resourceType, &venue,
+ &strategy, &entityKind, &confidence, &source, &createdAt, &lastObserved); err != nil {
+ return nil, fmt.Errorf("patterns.Apply scan: %w", err)
+ }
+
+ tmplTokens := nonPlaceholderTokens(strings.Fields(queryTemplate))
+ score := jaccard(queryTokens, tmplTokens)
+ if score < jMin {
+ continue
+ }
+
+ var hit Hit
+ matched := false
+ for _, ent := range queryEntities {
+ candidate, ok := substituteCandidate(db, resourceTmpl, entityKind, ent, allKinds)
+ if !ok {
+ continue
+ }
+ h, verified := verifyCandidate(ctx, db, candidate, resourceType, strategy, opts.NoVerify)
+ if !verified {
+ continue
+ }
+ h.Venue = venue
+ h.Confidence = confidence
+ h.MatchScore = score
+ h.EntityMatch = "exact" // substitution binding guarantees this
+ h.Source = "pattern"
+ h.PatternID = id
+ if lastObserved.Valid {
+ t := lastObserved.Time
+ h.LastObservedAt = &t
+ }
+ hit = h
+ matched = true
+ break
+ }
+ if !matched {
+ continue
+ }
+ hits = append(hits, hit)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("patterns.Apply rows: %w", err)
+ }
+
+ sort.SliceStable(hits, func(i, j int) bool {
+ if hits[i].MatchScore != hits[j].MatchScore {
+ return hits[i].MatchScore > hits[j].MatchScore
+ }
+ if hits[i].Confidence != hits[j].Confidence {
+ return hits[i].Confidence > hits[j].Confidence
+ }
+ ai := time.Time{}
+ aj := time.Time{}
+ if hits[i].LastObservedAt != nil {
+ ai = *hits[i].LastObservedAt
+ }
+ if hits[j].LastObservedAt != nil {
+ aj = *hits[j].LastObservedAt
+ }
+ return ai.After(aj)
+ })
+
+ if len(hits) > limit {
+ hits = hits[:limit]
+ }
+ return hits, nil
+}
+
+// substituteCandidate fills in the {entity:kind} slot of
+// resource_template with lookups.Lookup(kind, entity). The slot may
+// appear with a typed kind or as a bare {entity} bound to the
+// pattern's default entity_kind.
+func substituteCandidate(db *sql.DB, resourceTmpl, defaultKind, entity string, allKinds []string) (string, bool) {
+ typedSlot := "{entity:" + defaultKind + "}"
+ if strings.Contains(resourceTmpl, typedSlot) {
+ v, ok, err := lookups.Lookup(db, defaultKind, entity)
+ if err != nil || !ok || v == "" {
+ return "", false
+ }
+ return strings.ReplaceAll(resourceTmpl, typedSlot, v), true
+ }
+ if strings.Contains(resourceTmpl, "{entity}") {
+ v, ok, err := lookups.Lookup(db, defaultKind, entity)
+ if err != nil || !ok || v == "" {
+ return "", false
+ }
+ return strings.ReplaceAll(resourceTmpl, "{entity}", v), true
+ }
+ for _, kind := range allKinds {
+ slot := "{entity:" + kind + "}"
+ if !strings.Contains(resourceTmpl, slot) {
+ continue
+ }
+ v, ok, err := lookups.Lookup(db, kind, entity)
+ if err != nil || !ok || v == "" {
+ return "", false
+ }
+ return strings.ReplaceAll(resourceTmpl, slot, v), true
+ }
+ return "", false
+}
+
+// verifyCandidate confirms the substituted candidate corresponds to a
+// real resource. Strategy-dispatch as documented on the Strategy
+// constants.
+func verifyCandidate(ctx context.Context, db *sql.DB, candidate, resourceType, strategy string, noVerify bool) (Hit, bool) {
+ switch strategy {
+ case StrategySubstitute:
+ if noVerify {
+ return Hit{ResourceID: candidate, ResourceType: resourceType}, true
+ }
+ var got string
+ err := db.QueryRowContext(ctx,
+ `SELECT id FROM resources WHERE resource_type = ? AND id = ?`,
+ resourceType, candidate,
+ ).Scan(&got)
+ if err != nil {
+ return Hit{}, false
+ }
+ return Hit{ResourceID: got, ResourceType: resourceType}, true
+
+ case StrategySubstituteThenSearchPrefix:
+ prefix := strings.TrimSuffix(candidate, "*")
+ if noVerify {
+ return Hit{ResourceID: prefix, ResourceType: resourceType}, true
+ }
+ var got string
+ err := db.QueryRowContext(ctx,
+ `SELECT id FROM resources WHERE resource_type = ? AND id LIKE ? LIMIT 1`,
+ resourceType, prefix+"%",
+ ).Scan(&got)
+ if err != nil {
+ return Hit{}, false
+ }
+ return Hit{ResourceID: got, ResourceType: resourceType}, true
+
+ default:
+ return Hit{}, false
+ }
+}
+
+func nonPlaceholderTokens(in []string) []string {
+ out := make([]string, 0, len(in))
+ for _, t := range in {
+ if strings.HasPrefix(t, "{entity") {
+ continue
+ }
+ out = append(out, t)
+ }
+ return out
+}
+
+func jaccard(a, b []string) float64 {
+ setA := setOf(a)
+ setB := setOf(b)
+ if len(setA) == 0 || len(setB) == 0 {
+ return 0
+ }
+ inter := 0
+ for tok := range setA {
+ if _, ok := setB[tok]; ok {
+ inter++
+ }
+ }
+ union := len(setA) + len(setB) - inter
+ if union == 0 {
+ return 0
+ }
+ return float64(inter) / float64(union)
+}
+
+func setOf(in []string) map[string]struct{} {
+ out := make(map[string]struct{}, len(in))
+ for _, t := range in {
+ t = strings.ToLower(strings.TrimSpace(t))
+ if t == "" {
+ continue
+ }
+ out[t] = struct{}{}
+ }
+ return out
+}
diff --git a/library/productivity/human-goat/internal/learn/patterns/apply_test.go b/library/productivity/human-goat/internal/learn/patterns/apply_test.go
new file mode 100644
index 0000000000..0b633ce52a
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/patterns/apply_test.go
@@ -0,0 +1,253 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package patterns
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+func openApplyTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "apply.db")
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ for _, q := range []string{
+ `CREATE TABLE resources (
+ resource_type TEXT NOT NULL,
+ id TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`,
+ `CREATE TABLE search_patterns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_template TEXT NOT NULL,
+ resource_template TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ venue TEXT,
+ strategy TEXT NOT NULL,
+ entity_kind TEXT NOT NULL,
+ confidence INTEGER NOT NULL DEFAULT 2,
+ source TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ example_query TEXT,
+ example_resource TEXT
+ )`,
+ `CREATE UNIQUE INDEX idx_patterns_unique ON search_patterns(query_template, resource_template, strategy)`,
+ `CREATE TABLE entity_lookups (
+ kind TEXT NOT NULL,
+ canonical TEXT NOT NULL,
+ value TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'seeded',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (kind, canonical, value)
+ )`,
+ } {
+ if _, err := db.Exec(q); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ }
+ return db
+}
+
+func seedResource(t *testing.T, db *sql.DB, rt, id, data string) {
+ t.Helper()
+ if _, err := db.Exec(`INSERT INTO resources (resource_type, id, data) VALUES (?, ?, ?)`, rt, id, data); err != nil {
+ t.Fatalf("seed resource: %v", err)
+ }
+}
+
+func seedLookup(t *testing.T, db *sql.DB, kind, canonical, value string) {
+ t.Helper()
+ if _, err := db.Exec(`INSERT INTO entity_lookups (kind, canonical, value, source) VALUES (?, ?, ?, 'seeded')`,
+ kind, canonical, value); err != nil {
+ t.Fatalf("seed lookup: %v", err)
+ }
+}
+
+// TestApply_SubstituteVerified is the flagship apply story: a pattern
+// is in the table; the live query asks about a new entity; Apply
+// substitutes via the seeded lookup and verifies against the seeded
+// resource.
+func TestApply_SubstituteVerified(t *testing.T) {
+ t.Parallel()
+ db := openApplyTestDB(t)
+ seedLookup(t, db, "team_code", "Charlie", "CH")
+ seedResource(t, db, "widgets", "PREFIX-CH", `{"title":"Charlie widget"}`)
+ if _, _, err := Upsert(db, Pattern{
+ QueryTemplate: "{entity} widget",
+ ResourceTemplate: "PREFIX-{entity:team_code}",
+ ResourceType: "widgets",
+ Venue: "alpha",
+ Strategy: StrategySubstitute,
+ EntityKind: "team_code",
+ Confidence: 2,
+ Source: SourceInferred,
+ }); err != nil {
+ t.Fatalf("upsert pattern: %v", err)
+ }
+ hits, err := Apply(context.Background(), db, "Charlie widget", "widget", []string{"Charlie"}, Opts{AdditionalKinds: []string{"team_code"}})
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if len(hits) != 1 {
+ t.Fatalf("want 1 hit, got %d: %+v", len(hits), hits)
+ }
+ if hits[0].ResourceID != "PREFIX-CH" {
+ t.Errorf("hit.ResourceID = %q, want PREFIX-CH", hits[0].ResourceID)
+ }
+ if hits[0].Source != "pattern" {
+ t.Errorf("hit.Source = %q, want pattern", hits[0].Source)
+ }
+ if hits[0].EntityMatch != "exact" {
+ t.Errorf("hit.EntityMatch = %q, want exact", hits[0].EntityMatch)
+ }
+}
+
+// TestApply_LookupMissNoHit: pattern exists, query carries a matching
+// entity token, but no lookup row for that entity under the pattern's
+// kind. Apply must skip rather than emit a hallucinated candidate.
+func TestApply_LookupMissNoHit(t *testing.T) {
+ t.Parallel()
+ db := openApplyTestDB(t)
+ if _, _, err := Upsert(db, Pattern{
+ QueryTemplate: "{entity} widget",
+ ResourceTemplate: "PREFIX-{entity:team_code}",
+ ResourceType: "widgets",
+ Strategy: StrategySubstitute,
+ EntityKind: "team_code",
+ Confidence: 2,
+ Source: SourceInferred,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ hits, err := Apply(context.Background(), db, "Atlantis widget", "widget", []string{"Atlantis"}, Opts{AdditionalKinds: []string{"team_code"}})
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if len(hits) != 0 {
+ t.Errorf("want 0 hits on lookup miss, got %d", len(hits))
+ }
+}
+
+// TestApply_NoVerifySkipsResourcesLookup: NoVerify returns the
+// substituted candidate verbatim without consulting the resources
+// table.
+func TestApply_NoVerifySkipsResourcesLookup(t *testing.T) {
+ t.Parallel()
+ db := openApplyTestDB(t)
+ seedLookup(t, db, "team_code", "Charlie", "CH")
+ if _, _, err := Upsert(db, Pattern{
+ QueryTemplate: "{entity} widget",
+ ResourceTemplate: "PREFIX-{entity:team_code}",
+ ResourceType: "widgets",
+ Strategy: StrategySubstitute,
+ EntityKind: "team_code",
+ Confidence: 2,
+ Source: SourceInferred,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ hits, err := Apply(context.Background(), db, "Charlie widget", "widget", []string{"Charlie"}, Opts{NoVerify: true, AdditionalKinds: []string{"team_code"}})
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if len(hits) != 1 {
+ t.Fatalf("noverify: want 1 hit, got %d", len(hits))
+ }
+ if hits[0].ResourceID != "PREFIX-CH" {
+ t.Errorf("noverify hit.ResourceID = %q", hits[0].ResourceID)
+ }
+}
+
+// TestApply_PrefixSearchPattern: prefix-strategy pattern + seeded
+// slug-style resource id.
+func TestApply_PrefixSearchPattern(t *testing.T) {
+ t.Parallel()
+ db := openApplyTestDB(t)
+ seedResource(t, db, "items", "slug-charlie-foo-318", `{"id":"slug-charlie-foo-318"}`)
+ if _, _, err := Upsert(db, Pattern{
+ QueryTemplate: "{entity} widget",
+ ResourceTemplate: "slug-{entity:lowercase}-foo-*",
+ ResourceType: "items",
+ Strategy: StrategySubstituteThenSearchPrefix,
+ EntityKind: "lowercase",
+ Confidence: 2,
+ Source: SourceInferred,
+ }); err != nil {
+ t.Fatalf("upsert prefix: %v", err)
+ }
+ hits, err := Apply(context.Background(), db, "Charlie widget", "widget", []string{"Charlie"}, Opts{})
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if len(hits) != 1 {
+ t.Fatalf("want 1 hit, got %d", len(hits))
+ }
+ if hits[0].ResourceID != "slug-charlie-foo-318" {
+ t.Errorf("prefix hit.ResourceID = %q", hits[0].ResourceID)
+ }
+}
+
+// TestApply_NoEntitiesNoHits: query with no entity tokens can't trigger
+// any pattern substitution.
+func TestApply_NoEntitiesNoHits(t *testing.T) {
+ t.Parallel()
+ db := openApplyTestDB(t)
+ if _, _, err := Upsert(db, Pattern{
+ QueryTemplate: "{entity} widget",
+ ResourceTemplate: "PREFIX-{entity:team_code}",
+ ResourceType: "widgets",
+ Strategy: StrategySubstitute,
+ EntityKind: "team_code",
+ Confidence: 2,
+ Source: SourceInferred,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ hits, err := Apply(context.Background(), db, "trending things", "things trending", nil, Opts{})
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if len(hits) != 0 {
+ t.Errorf("want 0 hits with no entities, got %d", len(hits))
+ }
+}
+
+// TestApply_BelowJaccardThresholdNoHit: query whose non-entity tokens
+// don't overlap with the pattern's query_template should not match.
+func TestApply_BelowJaccardThresholdNoHit(t *testing.T) {
+ t.Parallel()
+ db := openApplyTestDB(t)
+ seedLookup(t, db, "team_code", "Charlie", "CH")
+ seedResource(t, db, "widgets", "PREFIX-CH", `{}`)
+ if _, _, err := Upsert(db, Pattern{
+ QueryTemplate: "{entity} widget",
+ ResourceTemplate: "PREFIX-{entity:team_code}",
+ ResourceType: "widgets",
+ Strategy: StrategySubstitute,
+ EntityKind: "team_code",
+ Confidence: 2,
+ Source: SourceInferred,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ hits, err := Apply(context.Background(), db, "Charlie election results", "election results", []string{"Charlie"}, Opts{AdditionalKinds: []string{"team_code"}})
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if len(hits) != 0 {
+ t.Errorf("want 0 hits below Jaccard threshold, got %d", len(hits))
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/patterns/doc.go b/library/productivity/human-goat/internal/learn/patterns/doc.go
new file mode 100644
index 0000000000..0fef11fce9
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/patterns/doc.go
@@ -0,0 +1,84 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package patterns implements the generalization layer of the learning
+// subsystem: when a user has taught the CLI to map two or more
+// structurally-similar queries to two or more structurally-similar
+// resources, this package extracts a template ("pattern") from the
+// group and applies it to substitute new entities at recall time.
+//
+// # The story this package tells
+//
+// A user teaches:
+//
+// teach --query "rate alpha widget" --resource WIDGET-AL
+// teach --query "rate bravo widget" --resource WIDGET-BR
+//
+// After Extract runs, search_patterns carries:
+//
+// { query_template: "{entity} rate widget",
+// resource_template: "WIDGET-{entity:upper}",
+// resource_type: "widgets",
+// strategy: "substitute",
+// entity_kind: "upper",
+// source: "inferred",
+// confidence: 2 }
+//
+// On the next recall call:
+//
+// recall "rate charlie widget"
+//
+// the direct-lookup path returns no hits (Charlie was never taught).
+// patterns.Apply walks search_patterns, matches the non-entity
+// normalized form against the query_template, and substitutes
+// lookups.Lookup("upper", "Charlie") -> "CHARLIE" into the
+// resource_template, yielding the candidate ID WIDGET-CHARLIE. The
+// candidate is verified against the local resources table; the match
+// is returned with source="pattern" and entity_match="exact" because
+// the substitution binding guarantees the entity is present in the
+// resource ID.
+//
+// # Lifecycle
+//
+// 1. Teach side. CLI `teach` writes one search_learnings row per
+// (query, resource) pair via store.UpsertLearning. After a
+// successful upsert, the CLI fires Extract on the current DB. The
+// call is cheap, idempotent (the (query_template, resource_template,
+// strategy) unique index silences duplicates) and safe to run on
+// every teach.
+//
+// 2. Extract side. Walks the most recent N search_learnings rows,
+// groups them by structural signature, and for each group of size
+// >= 2 tries to find an entity_kind from the lookups table such
+// that substituting the lookup value for each row's query entity
+// reproduces that row's resource_id. When found, the group becomes
+// one search_patterns row.
+//
+// 3. Recall side. internal/learn/recall.go calls Apply after the
+// direct-lookup path. For each pattern whose query_template
+// matches the live query (token-set Jaccard >= the same recall
+// floor), substitute the live query's entity via lookups.Lookup,
+// verify the substituted candidate exists in the resources table
+// (or in a prefix LIKE search for the substitute-then-search-prefix
+// strategy), and emit it as a recall.Hit with source="pattern".
+//
+// # Substitution strategies
+//
+// Two strategies are supported, distinguished by the trailing shape of
+// the resource_template:
+//
+// - "substitute" — full deterministic ID. The resource_template names
+// exactly one resource per entity lookup.
+//
+// - "substitute-then-search-prefix" — the resource_template ends with
+// "*". The substituted candidate is treated as a LIKE prefix search
+// against the resources table.
+//
+// # Entity kinds
+//
+// Substitution kinds are resolved by internal/learn/lookups.Lookup.
+// Table-backed kinds (seeded at migration time) are tried before
+// computed kinds (lowercase, uppercase, kebab-case, capitalize-first,
+// slug). The extraction engine tries every legal kind for each group
+// and picks the first one that reproduces every member's resource_id.
+package patterns
diff --git a/library/productivity/human-goat/internal/learn/patterns/extract.go b/library/productivity/human-goat/internal/learn/patterns/extract.go
new file mode 100644
index 0000000000..0ad74d46df
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/patterns/extract.go
@@ -0,0 +1,380 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package patterns
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/lookups"
+)
+
+// extractWindow caps how many of the most-recently-observed
+// search_learnings rows the extractor considers per call.
+const extractWindow = 50
+
+// candidateKinds is the ordered set of lookup kinds Extract tries when
+// searching for an entity_kind binding. Order is significant:
+// table-backed kinds (registered via the CLI's LearnConfig) are tried
+// before computed kinds (lowercase, kebab-case, slug). The generator
+// emits only the computed kinds here. Per-CLI seeded kinds are passed
+// in via Opts.AdditionalKinds at Apply time so the template stays
+// domain-neutral.
+var candidateKinds = []string{
+ "lowercase",
+ "uppercase",
+ "capitalize-first",
+ "kebab-case",
+ "slug",
+}
+
+// teachRow is the subset of search_learnings columns Extract reads.
+type teachRow struct {
+ id int64
+ queryPattern string
+ queryEntities []string
+ resourceID string
+ resourceType string
+ venue string
+}
+
+// Extract walks the most-recent search_learnings rows, groups them by
+// structural signature, infers an entity-kind binding for each group,
+// and writes one search_patterns row per successful inference. Returns
+// the count of NEW rows created.
+//
+// Idempotency: re-running Extract on the same DB state produces zero
+// new rows. The unique index on (query_template, resource_template,
+// strategy) catches duplicates; Upsert flips them into confidence
+// bumps.
+//
+// additionalKinds lets the CLI inject its registered table-backed
+// lookup kinds (e.g., seeded country codes, team abbreviations) so
+// Extract tries them ahead of the computed-only kinds list. Pass nil
+// to use only the built-in computed kinds.
+//
+// A nil db returns an error; an empty learnings table returns (0, nil).
+func Extract(db *sql.DB, additionalKinds []string) (int, error) {
+ if db == nil {
+ return 0, fmt.Errorf("patterns.Extract: db is nil")
+ }
+ allKinds := make([]string, 0, len(additionalKinds)+len(candidateKinds))
+ allKinds = append(allKinds, additionalKinds...)
+ allKinds = append(allKinds, candidateKinds...)
+
+ rows, err := db.Query(`SELECT id, query_pattern, COALESCE(query_entities, ''),
+ resource_id, COALESCE(resource_type, ''), COALESCE(venue, '')
+ FROM search_learnings
+ WHERE source IN ('taught', 'inferred-followup', 'inferred-reach', 'inferred-pair')
+ ORDER BY last_observed_at DESC, id DESC
+ LIMIT ?`, extractWindow)
+ if err != nil {
+ return 0, fmt.Errorf("patterns.Extract query: %w", err)
+ }
+ defer rows.Close()
+
+ var teaches []teachRow
+ for rows.Next() {
+ var r teachRow
+ var entJSON string
+ if err := rows.Scan(&r.id, &r.queryPattern, &entJSON, &r.resourceID, &r.resourceType, &r.venue); err != nil {
+ return 0, fmt.Errorf("patterns.Extract scan: %w", err)
+ }
+ ents, err := parseEntityJSON(entJSON)
+ if err != nil {
+ continue
+ }
+ if len(ents) == 0 {
+ continue
+ }
+ r.queryEntities = ents
+ teaches = append(teaches, r)
+ }
+ if err := rows.Err(); err != nil {
+ return 0, fmt.Errorf("patterns.Extract rows: %w", err)
+ }
+
+ groups := map[groupKey][]teachRow{}
+ for _, t := range teaches {
+ structural := queryStructural(t.queryPattern, t.queryEntities)
+ if structural == "" {
+ continue
+ }
+ key := groupKey{
+ queryPattern: structural,
+ resourceType: t.resourceType,
+ venue: t.venue,
+ }
+ groups[key] = append(groups[key], t)
+ }
+
+ created := 0
+ keys := make([]groupKey, 0, len(groups))
+ for k := range groups {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ if keys[i].queryPattern != keys[j].queryPattern {
+ return keys[i].queryPattern < keys[j].queryPattern
+ }
+ if keys[i].resourceType != keys[j].resourceType {
+ return keys[i].resourceType < keys[j].resourceType
+ }
+ return keys[i].venue < keys[j].venue
+ })
+
+ for _, key := range keys {
+ members := groups[key]
+ if len(members) < 2 {
+ continue
+ }
+ // Multi-entity members fall through to grouping (so the
+ // structural form generalizes correctly) but skip inference
+ // here — tryExactBinding / tryPrefixBinding both index
+ // queryEntities[0] only. Multi-entity binding is a future
+ // enhancement; emitting a single-slot template against a
+ // multi-entity row would synthesize a wrong pattern.
+ anyMulti := false
+ for _, m := range members {
+ if len(m.queryEntities) > 1 {
+ anyMulti = true
+ break
+ }
+ }
+ if anyMulti {
+ continue
+ }
+ pat, ok := inferPattern(db, key, members, allKinds)
+ if !ok {
+ continue
+ }
+ _, inserted, err := Upsert(db, pat)
+ if err != nil {
+ continue
+ }
+ if inserted {
+ created++
+ }
+ }
+ return created, nil
+}
+
+func inferPattern(db *sql.DB, key groupKey, members []teachRow, kinds []string) (Pattern, bool) {
+ if len(members) == 0 {
+ return Pattern{}, false
+ }
+ for _, kind := range kinds {
+ if tmpl, ok := tryExactBinding(db, kind, members); ok {
+ return Pattern{
+ QueryTemplate: buildQueryTemplate(key.queryPattern, members[0].queryEntities),
+ ResourceTemplate: tmpl,
+ ResourceType: key.resourceType,
+ Venue: key.venue,
+ Strategy: StrategySubstitute,
+ EntityKind: kind,
+ Confidence: DefaultConfidence,
+ Source: SourceInferred,
+ ExampleQuery: buildExampleQuery(key.queryPattern, members[0].queryEntities[0]),
+ ExampleResource: members[0].resourceID,
+ }, true
+ }
+ if tmpl, ok := tryPrefixBinding(db, kind, members); ok {
+ return Pattern{
+ QueryTemplate: buildQueryTemplate(key.queryPattern, members[0].queryEntities),
+ ResourceTemplate: tmpl,
+ ResourceType: key.resourceType,
+ Venue: key.venue,
+ Strategy: StrategySubstituteThenSearchPrefix,
+ EntityKind: kind,
+ Confidence: DefaultConfidence,
+ Source: SourceInferred,
+ ExampleQuery: buildExampleQuery(key.queryPattern, members[0].queryEntities[0]),
+ ExampleResource: members[0].resourceID,
+ }, true
+ }
+ }
+ return Pattern{}, false
+}
+
+func tryExactBinding(db *sql.DB, kind string, members []teachRow) (string, bool) {
+ values := make([]string, len(members))
+ for i, m := range members {
+ v, ok, err := lookups.Lookup(db, kind, m.queryEntities[0])
+ if err != nil || !ok || v == "" {
+ return "", false
+ }
+ values[i] = v
+ }
+ first := members[0]
+ idx := 0
+ for {
+ pos := strings.Index(first.resourceID[idx:], values[0])
+ if pos < 0 {
+ return "", false
+ }
+ pos += idx
+ prefix := first.resourceID[:pos]
+ suffix := first.resourceID[pos+len(values[0]):]
+ allMatch := true
+ for i, m := range members {
+ if m.resourceID != prefix+values[i]+suffix {
+ allMatch = false
+ break
+ }
+ }
+ if allMatch {
+ return prefix + entitySlot(kind) + suffix, true
+ }
+ idx = pos + 1
+ if idx >= len(first.resourceID) {
+ return "", false
+ }
+ }
+}
+
+func tryPrefixBinding(db *sql.DB, kind string, members []teachRow) (string, bool) {
+ values := make([]string, len(members))
+ for i, m := range members {
+ v, ok, err := lookups.Lookup(db, kind, m.queryEntities[0])
+ if err != nil || !ok || v == "" {
+ return "", false
+ }
+ values[i] = v
+ }
+ first := members[0]
+ idx := 0
+ for {
+ pos := strings.Index(first.resourceID[idx:], values[0])
+ if pos < 0 {
+ return "", false
+ }
+ pos += idx
+ prefix := first.resourceID[:pos]
+ suffixFirst := first.resourceID[pos+len(values[0]):]
+ sharedSuffix := suffixFirst
+ ok := true
+ for i := 1; i < len(members); i++ {
+ m := members[i]
+ if !strings.HasPrefix(m.resourceID, prefix) {
+ ok = false
+ break
+ }
+ if !strings.HasPrefix(m.resourceID[len(prefix):], values[i]) {
+ ok = false
+ break
+ }
+ suffixI := m.resourceID[len(prefix)+len(values[i]):]
+ sharedSuffix = longestCommonPrefix(sharedSuffix, suffixI)
+ }
+ if !ok {
+ idx = pos + 1
+ if idx >= len(first.resourceID) {
+ return "", false
+ }
+ continue
+ }
+ if sharedSuffix == "" {
+ return "", false
+ }
+ if isFullyShared(sharedSuffix, suffixFirst, members, values, prefix) {
+ return "", false
+ }
+ return prefix + entitySlot(kind) + sharedSuffix + "*", true
+ }
+}
+
+func isFullyShared(sharedSuffix, suffixFirst string, members []teachRow, values []string, prefix string) bool {
+ if sharedSuffix != suffixFirst {
+ return false
+ }
+ for i := 1; i < len(members); i++ {
+ suffixI := members[i].resourceID[len(prefix)+len(values[i]):]
+ if suffixI != sharedSuffix {
+ return false
+ }
+ }
+ return true
+}
+
+// queryStructural strips every stored entity from queryPattern,
+// returning the alphabetized non-entity token form so two queries
+// about different entities ("alpha doing season" and "bravo doing
+// season") collapse to the same structural key. Multi-entity queries
+// have all entities stripped — single-entity-only stripping was
+// blocking pattern emergence whenever queryEntities held more than
+// one element.
+func queryStructural(queryPattern string, entitiesToStrip []string) string {
+ tokens := strings.Fields(queryPattern)
+ skip := make(map[string]struct{}, len(entitiesToStrip))
+ for _, e := range entitiesToStrip {
+ if v := strings.ToLower(strings.TrimSpace(e)); v != "" {
+ skip[v] = struct{}{}
+ }
+ }
+ kept := make([]string, 0, len(tokens))
+ for _, t := range tokens {
+ if _, drop := skip[strings.ToLower(t)]; drop {
+ continue
+ }
+ kept = append(kept, t)
+ }
+ sort.Strings(kept)
+ return strings.Join(kept, " ")
+}
+
+// buildQueryTemplate returns the structural form with a single
+// {entity} placeholder appended. inferPattern guards against
+// multi-entity members so this single-slot shape stays correct.
+func buildQueryTemplate(queryPattern string, entitiesToStrip []string) string {
+ structural := queryStructural(queryPattern, entitiesToStrip)
+ tokens := strings.Fields(structural)
+ tokens = append(tokens, "{entity}")
+ sort.Strings(tokens)
+ return strings.Join(tokens, " ")
+}
+
+func buildExampleQuery(queryPattern, entity string) string {
+ if queryPattern == "" {
+ return entity
+ }
+ return queryPattern + " " + entity
+}
+
+func entitySlot(kind string) string {
+ return "{entity:" + kind + "}"
+}
+
+func longestCommonPrefix(a, b string) string {
+ n := len(a)
+ if len(b) < n {
+ n = len(b)
+ }
+ for i := 0; i < n; i++ {
+ if a[i] != b[i] {
+ return a[:i]
+ }
+ }
+ return a[:n]
+}
+
+func parseEntityJSON(raw string) ([]string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "null" {
+ return nil, nil
+ }
+ var out []string
+ if err := json.Unmarshal([]byte(raw), &out); err != nil {
+ return nil, fmt.Errorf("parse entities: %w", err)
+ }
+ return out, nil
+}
+
+type groupKey struct {
+ queryPattern string
+ resourceType string
+ venue string
+}
diff --git a/library/productivity/human-goat/internal/learn/patterns/extract_test.go b/library/productivity/human-goat/internal/learn/patterns/extract_test.go
new file mode 100644
index 0000000000..bcb12f647d
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/patterns/extract_test.go
@@ -0,0 +1,322 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package patterns
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+// openExtractTestDB sets up search_learnings + search_patterns +
+// entity_lookups so Extract can do its full read-+-bind-+-write pass.
+func openExtractTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "extract.db")
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ for _, q := range []string{
+ `CREATE TABLE search_learnings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_pattern TEXT NOT NULL,
+ query_entities TEXT,
+ venue TEXT,
+ resource_type TEXT,
+ resource_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ alias_target TEXT,
+ source TEXT NOT NULL,
+ confidence INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ notes TEXT
+ )`,
+ `CREATE TABLE search_patterns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_template TEXT NOT NULL,
+ resource_template TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ venue TEXT,
+ strategy TEXT NOT NULL,
+ entity_kind TEXT NOT NULL,
+ confidence INTEGER NOT NULL DEFAULT 2,
+ source TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ example_query TEXT,
+ example_resource TEXT
+ )`,
+ `CREATE UNIQUE INDEX idx_patterns_unique ON search_patterns(query_template, resource_template, strategy)`,
+ `CREATE TABLE entity_lookups (
+ kind TEXT NOT NULL,
+ canonical TEXT NOT NULL,
+ value TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'seeded',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (kind, canonical, value)
+ )`,
+ } {
+ if _, err := db.Exec(q); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ }
+ return db
+}
+
+func insertLearning(t *testing.T, db *sql.DB, qp, entities, resourceID, rt, venue string) {
+ t.Helper()
+ if _, err := db.Exec(`INSERT INTO search_learnings (query_pattern, query_entities, resource_id, resource_type, venue, action, confidence, source)
+ VALUES (?, ?, ?, ?, ?, 'boost', 2, 'taught')`,
+ qp, entities, resourceID, rt, venue); err != nil {
+ t.Fatalf("insert learning: %v", err)
+ }
+}
+
+func insertLookup(t *testing.T, db *sql.DB, kind, canonical, value string) {
+ t.Helper()
+ if _, err := db.Exec(`INSERT INTO entity_lookups (kind, canonical, value, source) VALUES (?, ?, ?, 'seeded')`,
+ kind, canonical, value); err != nil {
+ t.Fatalf("insert lookup: %v", err)
+ }
+}
+
+// TestExtract_ExactSubstitute_TwoTeachesProducePattern is the flagship
+// "two teaches with a single-token swap" extraction story.
+func TestExtract_ExactSubstitute_TwoTeachesProducePattern(t *testing.T) {
+ t.Parallel()
+ db := openExtractTestDB(t)
+ insertLookup(t, db, "team_code", "Alpha", "AL")
+ insertLookup(t, db, "team_code", "Bravo", "BR")
+ insertLearning(t, db, "alpha widget", `["Alpha"]`, "PREFIX-AL", "widgets", "")
+ insertLearning(t, db, "bravo widget", `["Bravo"]`, "PREFIX-BR", "widgets", "")
+
+ created, err := Extract(db, []string{"team_code"})
+ if err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+ if created < 1 {
+ t.Errorf("Extract created %d, want >= 1", created)
+ }
+
+ rows, err := List(db, ListFilter{})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ var found *Pattern
+ for i := range rows {
+ if rows[i].EntityKind == "team_code" && rows[i].Strategy == StrategySubstitute {
+ found = &rows[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("no team_code/substitute pattern found in %+v", rows)
+ }
+ if found.ResourceTemplate != "PREFIX-{entity:team_code}" {
+ t.Errorf("resource_template = %q, want PREFIX-{entity:team_code}", found.ResourceTemplate)
+ }
+}
+
+// TestExtract_PrefixSearch_TwoTeachesProducePrefixPattern.
+func TestExtract_PrefixSearch_TwoTeachesProducePrefixPattern(t *testing.T) {
+ t.Parallel()
+ db := openExtractTestDB(t)
+ insertLearning(t, db, "alpha widget", `["Alpha"]`, "slug-alpha-foo-912", "items", "")
+ insertLearning(t, db, "bravo widget", `["Bravo"]`, "slug-bravo-foo-467", "items", "")
+
+ if _, err := Extract(db, nil); err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+ rows, err := List(db, ListFilter{Strategy: StrategySubstituteThenSearchPrefix})
+ if err != nil {
+ t.Fatalf("list prefix: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("want 1 prefix pattern, got %d: %+v", len(rows), rows)
+ }
+ if rows[0].ResourceTemplate == "" || rows[0].ResourceTemplate[len(rows[0].ResourceTemplate)-1] != '*' {
+ t.Errorf("resource_template should end with '*'; got %q", rows[0].ResourceTemplate)
+ }
+}
+
+// TestExtract_SingleTeachProducesNoPattern: one teach isn't a pattern.
+func TestExtract_SingleTeachProducesNoPattern(t *testing.T) {
+ t.Parallel()
+ db := openExtractTestDB(t)
+ insertLearning(t, db, "alpha widget", `["Alpha"]`, "PREFIX-AL", "widgets", "")
+ if _, err := Extract(db, nil); err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+ rows, err := List(db, ListFilter{})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(rows) != 0 {
+ t.Errorf("single teach should not produce a pattern; got %d", len(rows))
+ }
+}
+
+// TestExtract_UnrelatedTeachesProduceNoPattern.
+func TestExtract_UnrelatedTeachesProduceNoPattern(t *testing.T) {
+ t.Parallel()
+ db := openExtractTestDB(t)
+ insertLearning(t, db, "alpha widget", `["Alpha"]`, "PREFIX-AL", "widgets", "")
+ insertLearning(t, db, "trending today", `["Today"]`, "trend-today", "items", "")
+ if _, err := Extract(db, nil); err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+ rows, _ := List(db, ListFilter{})
+ if len(rows) != 0 {
+ t.Errorf("unrelated teaches should not produce a pattern; got %+v", rows)
+ }
+}
+
+// TestQueryStructural_StripsAllEntities locks the multi-entity strip
+// contract: queryStructural takes a slice of entities and strips every
+// one, not just the first. Without this, multi-entity teaches lived in
+// different structural groups purely because the second entity
+// differed.
+func TestQueryStructural_StripsAllEntities(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ pattern string
+ entities []string
+ want string
+ }{
+ {
+ name: "single entity",
+ pattern: "alpha doing widget",
+ entities: []string{"alpha"},
+ want: "doing widget",
+ },
+ {
+ name: "two entities both stripped",
+ pattern: "alpha vs bravo tonight",
+ entities: []string{"alpha", "bravo"},
+ want: "tonight vs",
+ },
+ {
+ name: "case-insensitive stripping",
+ pattern: "Alpha vs Bravo tonight",
+ entities: []string{"Alpha", "Bravo"},
+ want: "tonight vs",
+ },
+ {
+ name: "empty entities leaves pattern",
+ pattern: "doing widget thing",
+ entities: nil,
+ want: "doing thing widget",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := queryStructural(tc.pattern, tc.entities)
+ if got != tc.want {
+ t.Errorf("queryStructural(%q, %v) = %q, want %q", tc.pattern, tc.entities, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestExtract_SingleEntityInference_ThirdQueryMatchesTemplate is a
+// regression check that two single-entity teaches in the same shape
+// still produce a template after the slice-signature refactor. The
+// emitted query_template should match the structural form of a third
+// same-shape query.
+func TestExtract_SingleEntityInference_ThirdQueryMatchesTemplate(t *testing.T) {
+ t.Parallel()
+ db := openExtractTestDB(t)
+ insertLookup(t, db, "source_type", "Alpha", "AL")
+ insertLookup(t, db, "source_type", "Bravo", "BR")
+ insertLearning(t, db, "alpha widget", `["Alpha"]`, "PREFIX-AL", "widgets", "")
+ insertLearning(t, db, "bravo widget", `["Bravo"]`, "PREFIX-BR", "widgets", "")
+
+ if _, err := Extract(db, []string{"source_type"}); err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+ rows, err := List(db, ListFilter{})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ var found *Pattern
+ for i := range rows {
+ if rows[i].EntityKind == "source_type" && rows[i].Strategy == StrategySubstitute {
+ found = &rows[i]
+ break
+ }
+ }
+ if found == nil {
+ t.Fatalf("no source_type/substitute pattern found in %+v", rows)
+ }
+ // The structural form of a third single-entity query "charlie
+ // widget" with entity ["Charlie"] strips "charlie" and appends
+ // {entity}, producing "widget {entity}" (alphabetized). That must
+ // equal the inferred QueryTemplate.
+ wantTemplate := buildQueryTemplate("charlie widget", []string{"Charlie"})
+ if found.QueryTemplate != wantTemplate {
+ t.Errorf("query_template = %q, want %q (third-query match)", found.QueryTemplate, wantTemplate)
+ }
+}
+
+// TestExtract_MultiEntity_NoSingleSlotPattern confirms a multi-entity
+// teach lives in the same structural group as its shape-peers (which
+// enables future multi-entity binding) but does NOT emit a single-slot
+// template, since tryExactBinding / tryPrefixBinding only index
+// queryEntities[0]. The anyMulti guard skips inference for any group
+// containing a multi-entity member.
+func TestExtract_MultiEntity_NoSingleSlotPattern(t *testing.T) {
+ t.Parallel()
+ db := openExtractTestDB(t)
+ insertLookup(t, db, "source_type", "Alpha", "AL")
+ insertLookup(t, db, "source_type", "Bravo", "BR")
+ insertLookup(t, db, "source_type", "Charlie", "CH")
+ insertLookup(t, db, "source_type", "Delta", "DE")
+ insertLearning(t, db, "tonight alpha vs bravo", `["Alpha","Bravo"]`, "ev1", "events", "")
+ insertLearning(t, db, "tonight charlie vs delta", `["Charlie","Delta"]`, "ev2", "events", "")
+
+ if _, err := Extract(db, []string{"source_type"}); err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+ rows, _ := List(db, ListFilter{})
+ if len(rows) != 0 {
+ t.Errorf("multi-entity members should not emit a single-slot pattern; got %+v", rows)
+ }
+}
+
+// TestExtract_Idempotent.
+func TestExtract_Idempotent(t *testing.T) {
+ t.Parallel()
+ db := openExtractTestDB(t)
+ insertLookup(t, db, "team_code", "Alpha", "AL")
+ insertLookup(t, db, "team_code", "Bravo", "BR")
+ insertLearning(t, db, "alpha widget", `["Alpha"]`, "PREFIX-AL", "widgets", "")
+ insertLearning(t, db, "bravo widget", `["Bravo"]`, "PREFIX-BR", "widgets", "")
+
+ if _, err := Extract(db, []string{"team_code"}); err != nil {
+ t.Fatalf("first extract: %v", err)
+ }
+ firstRows, _ := List(db, ListFilter{})
+ firstCount := len(firstRows)
+ if firstCount == 0 {
+ t.Fatalf("first extract produced 0 rows")
+ }
+ if _, err := Extract(db, []string{"team_code"}); err != nil {
+ t.Fatalf("second extract: %v", err)
+ }
+ secondRows, _ := List(db, ListFilter{})
+ if len(secondRows) != firstCount {
+ t.Errorf("re-extract drifted: first=%d second=%d", firstCount, len(secondRows))
+ }
+ for _, r := range secondRows {
+ if r.Confidence < firstRows[0].Confidence {
+ t.Errorf("confidence regressed on re-extract")
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/patterns/store.go b/library/productivity/human-goat/internal/learn/patterns/store.go
new file mode 100644
index 0000000000..6ba4e70a20
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/patterns/store.go
@@ -0,0 +1,299 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package patterns
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Default confidence floor for an inferred pattern. Mirrors the
+// search_learnings teach-side floor in store.UpsertLearning (also 2) so
+// pattern hits and direct teaches sort comparably when recall merges
+// them.
+const DefaultConfidence = 2
+
+// Strategy values for Pattern.Strategy. Kept as const strings to keep
+// the column a stable schema for tests and the LLM-readable envelope.
+const (
+ // StrategySubstitute names a fully-deterministic resource ID:
+ // substituting the entity into the resource_template yields exactly
+ // one candidate which Apply verifies by direct lookup in the
+ // resources table.
+ StrategySubstitute = "substitute"
+
+ // StrategySubstituteThenSearchPrefix names a partially-deterministic
+ // resource ID: the resource_template ends with "*" and Apply does a
+ // LIKE prefix search in the resources table after substitution.
+ StrategySubstituteThenSearchPrefix = "substitute-then-search-prefix"
+)
+
+// Source values for Pattern.Source. "inferred" rows come from Extract;
+// "taught" rows come from the teach-pattern CLI command.
+const (
+ SourceInferred = "inferred"
+ SourceTaught = "taught"
+)
+
+// Pattern is one row of the search_patterns table. It encodes a
+// template-with-typed-entity-slot pattern: given an input query that
+// matches QueryTemplate (modulo one entity slot), the engine
+// substitutes the live query's entity via lookups.Lookup(EntityKind,
+// ...) into ResourceTemplate and returns the result as a candidate
+// resource.
+//
+// Field order matches the table column order so callers writing
+// Pattern literals can mirror the schema. JSON tags exist because the
+// teach-pattern CLI and `patterns list --json` emit Pattern envelopes.
+type Pattern struct {
+ ID int64 `json:"id"`
+ QueryTemplate string `json:"query_template"`
+ ResourceTemplate string `json:"resource_template"`
+ ResourceType string `json:"resource_type"`
+ Venue string `json:"venue,omitempty"`
+ Strategy string `json:"strategy"`
+ EntityKind string `json:"entity_kind"`
+ Confidence int `json:"confidence"`
+ Source string `json:"source"`
+ CreatedAt time.Time `json:"created_at"`
+ LastObservedAt *time.Time `json:"last_observed_at,omitempty"`
+ ExampleQuery string `json:"example_query,omitempty"`
+ ExampleResource string `json:"example_resource,omitempty"`
+}
+
+// ListFilter narrows List. Zero values are unfiltered. EntityKind,
+// Strategy, Source, ResourceType, Venue match by equality; Limit
+// defaults to 200 when zero.
+type ListFilter struct {
+ EntityKind string
+ Strategy string
+ Source string
+ ResourceType string
+ Venue string
+ Limit int
+}
+
+// ForgetFilter selects rows for Forget. At least one of EntityKind,
+// QueryTemplate, ResourceTemplate, or All must be set; otherwise
+// Forget returns an error rather than wiping the whole table.
+type ForgetFilter struct {
+ EntityKind string
+ QueryTemplate string
+ ResourceTemplate string
+ All bool
+}
+
+// Upsert inserts a pattern row, or — when (query_template,
+// resource_template, strategy) already exists — bumps confidence by 1
+// and refreshes last_observed_at. Returns the row ID and a bool
+// indicating whether the row was newly inserted.
+//
+// Idempotency is built on the unique index idx_patterns_unique. This
+// is the contract Extract relies on: a re-Extract pass over the same
+// learnings produces zero new rows but bumps confidence on the
+// matching existing rows.
+func Upsert(db *sql.DB, p Pattern) (int64, bool, error) {
+ if db == nil {
+ return 0, false, errors.New("patterns.Upsert: db is nil")
+ }
+ if strings.TrimSpace(p.QueryTemplate) == "" {
+ return 0, false, errors.New("patterns.Upsert: query_template is required")
+ }
+ if strings.TrimSpace(p.ResourceTemplate) == "" {
+ return 0, false, errors.New("patterns.Upsert: resource_template is required")
+ }
+ if strings.TrimSpace(p.Strategy) == "" {
+ return 0, false, errors.New("patterns.Upsert: strategy is required")
+ }
+ if strings.TrimSpace(p.EntityKind) == "" {
+ return 0, false, errors.New("patterns.Upsert: entity_kind is required")
+ }
+ if strings.TrimSpace(p.ResourceType) == "" {
+ return 0, false, errors.New("patterns.Upsert: resource_type is required")
+ }
+ if p.Confidence <= 0 {
+ p.Confidence = DefaultConfidence
+ }
+ if strings.TrimSpace(p.Source) == "" {
+ p.Source = SourceInferred
+ }
+
+ now := time.Now().UTC()
+
+ tx, err := db.Begin()
+ if err != nil {
+ return 0, false, fmt.Errorf("patterns.Upsert begin: %w", err)
+ }
+ defer tx.Rollback()
+
+ var existingID int64
+ err = tx.QueryRow(
+ `SELECT id FROM search_patterns
+ WHERE query_template = ? AND resource_template = ? AND strategy = ?`,
+ p.QueryTemplate, p.ResourceTemplate, p.Strategy,
+ ).Scan(&existingID)
+ if err == nil {
+ if _, err := tx.Exec(
+ `UPDATE search_patterns
+ SET confidence = confidence + 1, last_observed_at = ?
+ WHERE id = ?`,
+ now, existingID,
+ ); err != nil {
+ return 0, false, fmt.Errorf("patterns.Upsert bump confidence: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, false, err
+ }
+ return existingID, false, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return 0, false, fmt.Errorf("patterns.Upsert lookup: %w", err)
+ }
+
+ var venue, exampleQuery, exampleResource any
+ if p.Venue != "" {
+ venue = p.Venue
+ }
+ if p.ExampleQuery != "" {
+ exampleQuery = p.ExampleQuery
+ }
+ if p.ExampleResource != "" {
+ exampleResource = p.ExampleResource
+ }
+
+ res, err := tx.Exec(
+ `INSERT INTO search_patterns
+ (query_template, resource_template, resource_type, venue, strategy, entity_kind,
+ confidence, source, created_at, last_observed_at, example_query, example_resource)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ p.QueryTemplate, p.ResourceTemplate, p.ResourceType, venue, p.Strategy, p.EntityKind,
+ p.Confidence, p.Source, now, now, exampleQuery, exampleResource,
+ )
+ if err != nil {
+ return 0, false, fmt.Errorf("patterns.Upsert insert: %w", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return 0, false, fmt.Errorf("patterns.Upsert last id: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, false, err
+ }
+ return id, true, nil
+}
+
+// List returns patterns filtered by f, ordered by last_observed_at
+// DESC, then confidence DESC, then id DESC. A nil db returns an error.
+func List(db *sql.DB, f ListFilter) ([]Pattern, error) {
+ if db == nil {
+ return nil, errors.New("patterns.List: db is nil")
+ }
+ clauses := []string{}
+ args := []any{}
+ if f.EntityKind != "" {
+ clauses = append(clauses, "entity_kind = ?")
+ args = append(args, f.EntityKind)
+ }
+ if f.Strategy != "" {
+ clauses = append(clauses, "strategy = ?")
+ args = append(args, f.Strategy)
+ }
+ if f.Source != "" {
+ clauses = append(clauses, "source = ?")
+ args = append(args, f.Source)
+ }
+ if f.ResourceType != "" {
+ clauses = append(clauses, "resource_type = ?")
+ args = append(args, f.ResourceType)
+ }
+ if f.Venue != "" {
+ clauses = append(clauses, "venue = ?")
+ args = append(args, f.Venue)
+ }
+ where := ""
+ if len(clauses) > 0 {
+ where = "WHERE " + strings.Join(clauses, " AND ")
+ }
+ limit := f.Limit
+ if limit <= 0 {
+ limit = 200
+ }
+ args = append(args, limit)
+
+ q := fmt.Sprintf(`SELECT id, query_template, resource_template, resource_type, COALESCE(venue, ''),
+ strategy, entity_kind, confidence, source, created_at, last_observed_at,
+ COALESCE(example_query, ''), COALESCE(example_resource, '')
+ FROM search_patterns %s
+ ORDER BY last_observed_at DESC, confidence DESC, id DESC
+ LIMIT ?`, where)
+
+ rows, err := db.Query(q, args...)
+ if err != nil {
+ return nil, fmt.Errorf("patterns.List query: %w", err)
+ }
+ defer rows.Close()
+
+ out := make([]Pattern, 0)
+ for rows.Next() {
+ var p Pattern
+ var lastObs sql.NullTime
+ if err := rows.Scan(&p.ID, &p.QueryTemplate, &p.ResourceTemplate, &p.ResourceType, &p.Venue,
+ &p.Strategy, &p.EntityKind, &p.Confidence, &p.Source, &p.CreatedAt, &lastObs,
+ &p.ExampleQuery, &p.ExampleResource); err != nil {
+ return nil, fmt.Errorf("patterns.List scan: %w", err)
+ }
+ if lastObs.Valid {
+ t := lastObs.Time
+ p.LastObservedAt = &t
+ }
+ out = append(out, p)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("patterns.List rows: %w", err)
+ }
+ return out, nil
+}
+
+// Forget deletes patterns matching f and returns the count removed.
+// Pass All=true to delete every row (the kill-switch for the manual
+// CLI surface); otherwise at least one of EntityKind, QueryTemplate,
+// or ResourceTemplate must be set, mirroring store.ForgetLearnings.
+func Forget(db *sql.DB, f ForgetFilter) (int64, error) {
+ if db == nil {
+ return 0, errors.New("patterns.Forget: db is nil")
+ }
+ if !f.All && f.EntityKind == "" && f.QueryTemplate == "" && f.ResourceTemplate == "" {
+ return 0, errors.New("patterns.Forget: pass --all, --entity-kind, --query-template, or --resource-template")
+ }
+ clauses := []string{}
+ args := []any{}
+ if f.EntityKind != "" {
+ clauses = append(clauses, "entity_kind = ?")
+ args = append(args, f.EntityKind)
+ }
+ if f.QueryTemplate != "" {
+ clauses = append(clauses, "query_template = ?")
+ args = append(args, f.QueryTemplate)
+ }
+ if f.ResourceTemplate != "" {
+ clauses = append(clauses, "resource_template = ?")
+ args = append(args, f.ResourceTemplate)
+ }
+ var q string
+ if len(clauses) == 0 {
+ // f.All == true path: wipe everything.
+ q = "DELETE FROM search_patterns"
+ } else {
+ q = "DELETE FROM search_patterns WHERE " + strings.Join(clauses, " AND ")
+ }
+ res, err := db.Exec(q, args...)
+ if err != nil {
+ return 0, fmt.Errorf("patterns.Forget: %w", err)
+ }
+ n, _ := res.RowsAffected()
+ return n, nil
+}
diff --git a/library/productivity/human-goat/internal/learn/patterns/store_test.go b/library/productivity/human-goat/internal/learn/patterns/store_test.go
new file mode 100644
index 0000000000..02a319be5d
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/patterns/store_test.go
@@ -0,0 +1,219 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package patterns
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+// openTestDB stands up the canonical search_patterns schema only.
+func openTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "patterns.db")
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ for _, q := range []string{
+ `CREATE TABLE search_patterns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_template TEXT NOT NULL,
+ resource_template TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ venue TEXT,
+ strategy TEXT NOT NULL,
+ entity_kind TEXT NOT NULL,
+ confidence INTEGER NOT NULL DEFAULT 2,
+ source TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ example_query TEXT,
+ example_resource TEXT
+ )`,
+ `CREATE UNIQUE INDEX idx_patterns_unique ON search_patterns(query_template, resource_template, strategy)`,
+ } {
+ if _, err := db.Exec(q); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ }
+ return db
+}
+
+func samplePattern() Pattern {
+ return Pattern{
+ QueryTemplate: "{entity} score widget",
+ ResourceTemplate: "PREFIX-{entity:team_code}",
+ ResourceType: "widgets",
+ Venue: "alpha",
+ Strategy: StrategySubstitute,
+ EntityKind: "team_code",
+ Confidence: 2,
+ Source: SourceTaught,
+ ExampleQuery: "Alpha score widget",
+ ExampleResource: "PREFIX-ALP",
+ }
+}
+
+func TestUpsert_InsertsNewRow(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ _, inserted, err := Upsert(db, samplePattern())
+ if err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ if !inserted {
+ t.Errorf("first upsert should be inserted=true")
+ }
+}
+
+func TestUpsert_DuplicateBumpsConfidence(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ p := samplePattern()
+ if _, _, err := Upsert(db, p); err != nil {
+ t.Fatalf("first: %v", err)
+ }
+ _, inserted, err := Upsert(db, p)
+ if err != nil {
+ t.Fatalf("second: %v", err)
+ }
+ if inserted {
+ t.Errorf("second upsert should be inserted=false")
+ }
+ rows, err := List(db, ListFilter{})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("want 1 row, got %d", len(rows))
+ }
+ if rows[0].Confidence != 3 {
+ t.Errorf("confidence after second upsert = %d, want 3", rows[0].Confidence)
+ }
+}
+
+func TestUpsert_RejectsMissingRequiredFields(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ cases := []struct {
+ name string
+ mutate func(*Pattern)
+ }{
+ {"empty query_template", func(p *Pattern) { p.QueryTemplate = "" }},
+ {"empty resource_template", func(p *Pattern) { p.ResourceTemplate = "" }},
+ {"empty strategy", func(p *Pattern) { p.Strategy = "" }},
+ {"empty entity_kind", func(p *Pattern) { p.EntityKind = "" }},
+ {"empty resource_type", func(p *Pattern) { p.ResourceType = "" }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ p := samplePattern()
+ tc.mutate(&p)
+ if _, _, err := Upsert(db, p); err == nil {
+ t.Errorf("expected error")
+ }
+ })
+ }
+}
+
+func TestList_FiltersByEntityKindAndStrategy(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+
+ r1 := samplePattern()
+ r2 := samplePattern()
+ r2.QueryTemplate = "{entity} score"
+ r2.ResourceTemplate = "slug-{entity:lowercase}-foo-*"
+ r2.Strategy = StrategySubstituteThenSearchPrefix
+ r2.EntityKind = "lowercase"
+ r2.ResourceType = "items"
+ r2.Venue = "bravo"
+ r2.ExampleResource = "slug-alpha-foo-456"
+
+ if _, _, err := Upsert(db, r1); err != nil {
+ t.Fatalf("upsert r1: %v", err)
+ }
+ if _, _, err := Upsert(db, r2); err != nil {
+ t.Fatalf("upsert r2: %v", err)
+ }
+
+ rows, err := List(db, ListFilter{EntityKind: "team_code"})
+ if err != nil {
+ t.Fatalf("list by kind: %v", err)
+ }
+ if len(rows) != 1 || rows[0].EntityKind != "team_code" {
+ t.Errorf("want 1 team_code row, got %+v", rows)
+ }
+
+ rows, err = List(db, ListFilter{Strategy: StrategySubstituteThenSearchPrefix})
+ if err != nil {
+ t.Fatalf("list by strategy: %v", err)
+ }
+ if len(rows) != 1 || rows[0].Strategy != StrategySubstituteThenSearchPrefix {
+ t.Errorf("want 1 prefix-strategy row, got %+v", rows)
+ }
+
+ rows, err = List(db, ListFilter{})
+ if err != nil {
+ t.Fatalf("list all: %v", err)
+ }
+ if len(rows) != 2 {
+ t.Errorf("want 2 rows total, got %d", len(rows))
+ }
+}
+
+func TestForget_RequiresFilterOrAll(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ if _, err := Forget(db, ForgetFilter{}); err == nil {
+ t.Errorf("empty filter: want error")
+ }
+}
+
+func TestForget_ByEntityKindDeletesMatching(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ r1 := samplePattern()
+ r2 := samplePattern()
+ r2.QueryTemplate = "other template"
+ r2.EntityKind = "lowercase"
+
+ if _, _, err := Upsert(db, r1); err != nil {
+ t.Fatalf("upsert r1: %v", err)
+ }
+ if _, _, err := Upsert(db, r2); err != nil {
+ t.Fatalf("upsert r2: %v", err)
+ }
+ n, err := Forget(db, ForgetFilter{EntityKind: "team_code"})
+ if err != nil {
+ t.Fatalf("forget: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("Forget removed %d, want 1", n)
+ }
+ rows, _ := List(db, ListFilter{})
+ if len(rows) != 1 || rows[0].EntityKind != "lowercase" {
+ t.Errorf("after Forget: want only lowercase row, got %+v", rows)
+ }
+}
+
+func TestForget_AllWipesEverything(t *testing.T) {
+ t.Parallel()
+ db := openTestDB(t)
+ if _, _, err := Upsert(db, samplePattern()); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ n, err := Forget(db, ForgetFilter{All: true})
+ if err != nil {
+ t.Fatalf("forget all: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("Forget All removed %d, want 1", n)
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/playbooks.go b/library/productivity/human-goat/internal/learn/playbooks.go
new file mode 100644
index 0000000000..de727d22c7
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/playbooks.go
@@ -0,0 +1,326 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+)
+
+// EntityResolver looks up a token in entity_lookups to find its
+// canonical(s). The recall path's CanonicalResolver implements this;
+// teach can build the same resolver to apply identical promotion at
+// write time, keeping the query_entities column symmetric with what
+// recall sees.
+//
+// Defined here (rather than in promote.go) because playbooks.go is the
+// first consumer in the generator's template build order. PromoteEntities
+// (promote.go) and the CanonicalResolver concrete type (recall.go) both
+// reference this same interface.
+type EntityResolver interface {
+ Resolve(token string) []string
+ ResolveSet(tokens []string) map[string]struct{}
+}
+
+// Playbook is the on-disk shape of a stored CLI choreography for a
+// query family. Steps are linear; no branching or conditionals in v1.
+// Either cmd or client_side is non-empty on each step.
+type Playbook struct {
+ QueryFamilyExamples []string `json:"query_family_examples,omitempty"`
+ Steps []PlaybookStep `json:"steps"`
+ EntitySlots []string `json:"entity_slots,omitempty"`
+ ExpectedToolCalls int `json:"expected_tool_calls,omitempty"`
+}
+
+// PlaybookStep is one entry in the choreography. Mutually exclusive
+// shapes:
+// - cmd: CLI command string with entity slots like "{team.id}",
+// replayed against the printed CLI. Optional pagination hint.
+// - client_side: post-process the previous step's result (rank_by,
+// filter, etc.). Args carry the parameters.
+type PlaybookStep struct {
+ Cmd string `json:"cmd,omitempty"`
+ ClientSide string `json:"client_side,omitempty"`
+ Args map[string]any `json:"args,omitempty"`
+ Purpose string `json:"purpose,omitempty"`
+ Pagination string `json:"pagination,omitempty"`
+}
+
+// ResolvedPlaybook wraps a Playbook with the per-call slot resolution
+// map: $TEAM -> {id, abbr, displayName, canonical}. Unresolvable slots
+// stay as the raw query token. The recall envelope embeds this.
+type ResolvedPlaybook struct {
+ Playbook Playbook `json:"playbook"`
+ SlotsResolved map[string]map[string]any `json:"slots_resolved,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ QueryFamily string `json:"query_family"`
+ Confidence int `json:"confidence,omitempty"`
+}
+
+// ParsePlaybookFile reads a JSON playbook file from disk and returns
+// the parsed Playbook. Empty/malformed files return an error with the
+// file path embedded.
+func ParsePlaybookFile(path string) (Playbook, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return Playbook{}, fmt.Errorf("read playbook %s: %w", path, err)
+ }
+ return ParsePlaybook(data, path)
+}
+
+// ParsePlaybook decodes a JSON byte slice into a Playbook. label is
+// used only in error messages (file path, "inline", etc.).
+func ParsePlaybook(data []byte, label string) (Playbook, error) {
+ data = []byte(strings.TrimSpace(string(data)))
+ if len(data) == 0 {
+ return Playbook{}, fmt.Errorf("parse playbook %s: empty", label)
+ }
+ var p Playbook
+ if err := json.Unmarshal(data, &p); err != nil {
+ return Playbook{}, fmt.Errorf("parse playbook %s: %w", label, err)
+ }
+ return p, nil
+}
+
+// MarshalPlaybook returns the canonical JSON form of a Playbook for
+// storage in learning_playbooks.playbook_json.
+func MarshalPlaybook(p Playbook) (string, error) {
+ out, err := json.Marshal(p)
+ if err != nil {
+ return "", fmt.Errorf("marshal playbook: %w", err)
+ }
+ return string(out), nil
+}
+
+// QueryFamily derives the structural family key from a NormalizedQuery.
+// Used at both teach time (UpsertPlaybook) and recall time (lookup),
+// so the same key resolves consistently across the two paths.
+//
+// Today the family is simply NonEntityNormalized — Normalize +
+// PromoteEntities already strips entities and stopwords and produces a
+// sorted-joined token bag, which is what "structural shape" means. The
+// function exists so future refinements (e.g., pluralization folding,
+// lemmatization) have a single place to land without rewriting teach
+// and recall in lockstep.
+func QueryFamily(normalized NormalizedQuery) string {
+ return normalized.NonEntityNormalized
+}
+
+// QueryFamilyPreFold derives the family key a pre-synonym-fold build
+// would have produced. Only meaningful on a NormalizedQuery built by
+// NormalizeUnfolded; used exclusively as the raw-key fallback in
+// LookupPlaybookByFamily so rows stored before folding shipped stay
+// reachable.
+func QueryFamilyPreFold(normalized NormalizedQuery) string {
+ return normalized.NonEntityNormalized
+}
+
+// FoldFamilyKey applies cfg's synonym folds to a stored query-family
+// key. Family keys are alphabetically sorted token bags, so a
+// multiword variant's tokens may be non-adjacent — folding uses bag
+// semantics (all variant tokens present, any order) and re-sorts.
+// Returns the key unchanged when nothing folds.
+func FoldFamilyKey(family string, cfg *entities.Config) string {
+ if cfg == nil || family == "" {
+ return family
+ }
+ tokens := strings.Fields(family)
+ folded := cfg.FoldTokenBag(tokens)
+ sort.Strings(folded)
+ return strings.Join(folded, " ")
+}
+
+// PlaybookFamilyRow is the raw learning_playbooks row shape the
+// dual-key lookup returns; the caller parses PlaybookJSON and resolves
+// slots as it would after a direct query.
+type PlaybookFamilyRow struct {
+ ID int64
+ QueryFamily string
+ PlaybookJSON string
+ NotesText string
+ Confidence int
+}
+
+// LookupPlaybookByFamily finds the learning_playbooks row for a query
+// family with synonym-fold awareness, so no stored row is ever
+// stranded under a pre-fold key:
+//
+// 1. Exact hit on the folded family key (the common case).
+// 2. Exact hit on rawFamily — the key a pre-fold build would have
+// derived from THIS query's phrasing (pass "" when unavailable).
+// 3. Lazy-rekey scan: fold every stored family key and compare; a
+// match means the row was stored under a different pre-fold
+// phrasing of the same family.
+//
+// A hit via 2 or 3 rekeys the row in place to the folded key, so the
+// next lookup resolves at step 1. Rekeying is idempotent and one-time
+// per row: once a row's key equals its folded form, steps 2 and 3 can
+// never select it again. The rekey UPDATE is best-effort — a write
+// failure (e.g. read-only store) still returns the row.
+func LookupPlaybookByFamily(ctx context.Context, db *sql.DB, cfg *entities.Config, family, rawFamily string) (PlaybookFamilyRow, bool, error) {
+ if family == "" {
+ return PlaybookFamilyRow{}, false, nil
+ }
+
+ row, ok, err := getPlaybookFamilyExact(ctx, db, family)
+ if err != nil || ok {
+ return row, ok, err
+ }
+
+ if rawFamily != "" && rawFamily != family {
+ row, ok, err = getPlaybookFamilyExact(ctx, db, rawFamily)
+ if err != nil {
+ return PlaybookFamilyRow{}, false, err
+ }
+ if ok {
+ return rekeyPlaybookFamily(ctx, db, row, family), true, nil
+ }
+ }
+
+ row, ok, err = scanPlaybookFamiliesByFold(ctx, db, cfg, family)
+ if err != nil || !ok {
+ return PlaybookFamilyRow{}, false, err
+ }
+ return rekeyPlaybookFamily(ctx, db, row, family), true, nil
+}
+
+// getPlaybookFamilyExact fetches a learning_playbooks row by exact
+// query_family key. sql.ErrNoRows maps to (zero, false, nil).
+func getPlaybookFamilyExact(ctx context.Context, db *sql.DB, family string) (PlaybookFamilyRow, bool, error) {
+ var (
+ row PlaybookFamilyRow
+ playbookJSON sql.NullString
+ notesText sql.NullString
+ )
+ err := db.QueryRowContext(ctx,
+ `SELECT id, query_family, COALESCE(playbook_json, ''), COALESCE(notes_text, ''), confidence
+ FROM learning_playbooks WHERE query_family = ?`,
+ family,
+ ).Scan(&row.ID, &row.QueryFamily, &playbookJSON, ¬esText, &row.Confidence)
+ if errors.Is(err, sql.ErrNoRows) {
+ return PlaybookFamilyRow{}, false, nil
+ }
+ if err != nil {
+ return PlaybookFamilyRow{}, false, err
+ }
+ row.PlaybookJSON = playbookJSON.String
+ row.NotesText = notesText.String
+ return row, true, nil
+}
+
+// scanPlaybookFamiliesByFold walks every stored family key, folds it,
+// and returns the first row whose folded form equals family. Linear in
+// the playbook count, which stays small (tens of rows) and is only
+// reached on a double exact-key miss.
+func scanPlaybookFamiliesByFold(ctx context.Context, db *sql.DB, cfg *entities.Config, family string) (PlaybookFamilyRow, bool, error) {
+ rows, err := db.QueryContext(ctx, `SELECT query_family FROM learning_playbooks ORDER BY id`)
+ if err != nil {
+ return PlaybookFamilyRow{}, false, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var stored string
+ if err := rows.Scan(&stored); err != nil {
+ return PlaybookFamilyRow{}, false, err
+ }
+ if stored == family || FoldFamilyKey(stored, cfg) != family {
+ continue
+ }
+ if err := rows.Close(); err != nil {
+ return PlaybookFamilyRow{}, false, err
+ }
+ return getPlaybookFamilyExact(ctx, db, stored)
+ }
+ return PlaybookFamilyRow{}, false, rows.Err()
+}
+
+// rekeyPlaybookFamily rewrites a row's query_family to the folded key
+// so subsequent lookups hit exactly. Best-effort: on UPDATE failure
+// the row is returned under its old key and the next lookup retries.
+// Safe against the UNIQUE constraint because the caller only rekeys
+// after an exact miss on the target key.
+func rekeyPlaybookFamily(ctx context.Context, db *sql.DB, row PlaybookFamilyRow, family string) PlaybookFamilyRow {
+ if row.QueryFamily == family {
+ return row
+ }
+ if _, err := db.ExecContext(ctx,
+ `UPDATE learning_playbooks SET query_family = ? WHERE id = ?`,
+ family, row.ID,
+ ); err != nil {
+ return row
+ }
+ row.QueryFamily = family
+ return row
+}
+
+// ResolveSlots walks the entity_slots declared on a Playbook and
+// resolves each one against the current query's entities + canonical
+// resolver. Returns a map keyed by slot name (e.g., "$TEAM") to a
+// metadata map ({"token": "pistons", "canonical": "Detroit Pistons"}).
+//
+// Slots that don't match any query entity stay absent from the map —
+// the agent reads "I have $TEAM bound to {...} but no $STATS bound,
+// the user must have meant differently" and decides.
+//
+// The resolver is the same EntityResolver interface PromoteEntities
+// uses, so this composes cleanly with the existing recall path.
+func ResolveSlots(p Playbook, normalized NormalizedQuery, r EntityResolver) map[string]map[string]any {
+ if len(p.EntitySlots) == 0 || r == nil {
+ return nil
+ }
+ out := make(map[string]map[string]any, len(p.EntitySlots))
+ // Slot binding must only consider tokens classified as entities
+ // (after PromoteEntities). Pulling in non-entity tokens would mean
+ // a non-entity token aliased in entity_lookups could win a slot
+ // that the playbook author intended for a real entity — e.g. a
+ // "$TEAM" slot getting bound to a stat-abbrev token if that token
+ // were ever added as a secondary alias. The docstring says "Slots
+ // that don't match any query entity stay absent"; restricting the
+ // pool to normalized.Entities matches that intent.
+ queryTokens := append([]string(nil), normalized.Entities...)
+ sort.Strings(queryTokens)
+
+ // Build a working set of unmatched tokens; mark off as slots claim them.
+ unclaimed := make(map[string]bool, len(queryTokens))
+ for _, t := range queryTokens {
+ unclaimed[t] = true
+ }
+
+ for _, slot := range p.EntitySlots {
+ // Take the first unclaimed token that resolves to a canonical.
+ // Slots in the playbook order are bound to entities in
+ // normalized-token order; multi-slot playbooks (e.g.,
+ // "$HOME vs $AWAY") need explicit ordering by their author.
+ for _, tok := range queryTokens {
+ if !unclaimed[tok] {
+ continue
+ }
+ cans := r.Resolve(tok)
+ if len(cans) == 0 {
+ continue
+ }
+ canonical := cans[0]
+ if len(cans) > 1 {
+ // Multiple canonicals; pick the first deterministically.
+ sort.Strings(cans)
+ canonical = cans[0]
+ }
+ out[slot] = map[string]any{
+ "token": tok,
+ "canonical": canonical,
+ }
+ unclaimed[tok] = false
+ break
+ }
+ }
+ return out
+}
diff --git a/library/productivity/human-goat/internal/learn/playbooks_test.go b/library/productivity/human-goat/internal/learn/playbooks_test.go
new file mode 100644
index 0000000000..29de3f7b42
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/playbooks_test.go
@@ -0,0 +1,562 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "os"
+ "path/filepath"
+ "testing"
+
+ _ "modernc.org/sqlite"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+)
+
+// stubResolver is a test-only EntityResolver backed by an in-memory
+// map. Lets the playbook tests exercise ResolveSlots without depending
+// on the canonical-resolver SQLite shape that lives elsewhere in the
+// learn package.
+type stubResolver struct {
+ canonicals map[string][]string
+}
+
+func newStubResolver() *stubResolver {
+ return &stubResolver{canonicals: map[string][]string{}}
+}
+
+func (s *stubResolver) set(token string, canonicals ...string) {
+ s.canonicals[token] = canonicals
+}
+
+func (s *stubResolver) Resolve(token string) []string {
+ if s == nil {
+ return nil
+ }
+ out := s.canonicals[token]
+ if len(out) == 0 {
+ return nil
+ }
+ return append([]string(nil), out...)
+}
+
+func (s *stubResolver) ResolveSet(tokens []string) map[string]struct{} {
+ if s == nil {
+ return nil
+ }
+ out := map[string]struct{}{}
+ for _, t := range tokens {
+ for _, c := range s.canonicals[t] {
+ out[c] = struct{}{}
+ }
+ }
+ return out
+}
+
+func TestParsePlaybook_HappyPath(t *testing.T) {
+ t.Parallel()
+ raw := []byte(`{
+ "query_family_examples": ["how did $X end the season"],
+ "steps": [
+ {"cmd": "teams basketball nba {team.id}", "purpose": "team object"},
+ {"client_side": "rank_by", "args": {"stats": "$STATS"}}
+ ],
+ "entity_slots": ["$X", "$STATS"],
+ "expected_tool_calls": 3
+ }`)
+ p, err := ParsePlaybook(raw, "inline-test")
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if len(p.Steps) != 2 {
+ t.Errorf("want 2 steps, got %d", len(p.Steps))
+ }
+ if p.Steps[0].Cmd == "" {
+ t.Errorf("first step should have cmd")
+ }
+ if p.Steps[1].ClientSide != "rank_by" {
+ t.Errorf("second step should be client_side rank_by, got %q", p.Steps[1].ClientSide)
+ }
+ if p.ExpectedToolCalls != 3 {
+ t.Errorf("expected_tool_calls = %d, want 3", p.ExpectedToolCalls)
+ }
+}
+
+func TestParsePlaybookFile_HappyPath(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "p.json")
+ if err := os.WriteFile(path, []byte(`{"steps":[{"cmd":"x"}],"entity_slots":["$X"]}`), 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ p, err := ParsePlaybookFile(path)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if len(p.Steps) != 1 || p.Steps[0].Cmd != "x" {
+ t.Errorf("unexpected: %+v", p)
+ }
+}
+
+func TestParsePlaybookFile_NonExistent(t *testing.T) {
+ t.Parallel()
+ _, err := ParsePlaybookFile("/definitely/does/not/exist.json")
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
+
+func TestParsePlaybook_Malformed(t *testing.T) {
+ t.Parallel()
+ _, err := ParsePlaybook([]byte(`{not json`), "bad")
+ if err == nil {
+ t.Fatal("expected parse error on malformed JSON")
+ }
+}
+
+func TestParsePlaybook_Empty(t *testing.T) {
+ t.Parallel()
+ _, err := ParsePlaybook([]byte(``), "empty")
+ if err == nil {
+ t.Fatal("expected error for empty input")
+ }
+}
+
+func TestMarshalPlaybook_RoundTrip(t *testing.T) {
+ t.Parallel()
+ orig := Playbook{
+ Steps: []PlaybookStep{{Cmd: "teams basketball nba {team.id}"}},
+ EntitySlots: []string{"$X"},
+ ExpectedToolCalls: 3,
+ }
+ enc, err := MarshalPlaybook(orig)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ decoded, err := ParsePlaybook([]byte(enc), "roundtrip")
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if len(decoded.Steps) != 1 || decoded.Steps[0].Cmd != orig.Steps[0].Cmd {
+ t.Errorf("roundtrip mismatch: %+v vs %+v", decoded, orig)
+ }
+ if len(decoded.EntitySlots) != 1 || decoded.EntitySlots[0] != "$X" {
+ t.Errorf("entity slots mismatch: %+v", decoded.EntitySlots)
+ }
+ if decoded.ExpectedToolCalls != 3 {
+ t.Errorf("expected_tool_calls mismatch: %d", decoded.ExpectedToolCalls)
+ }
+}
+
+func TestQueryFamily_StripsToNonEntityForm(t *testing.T) {
+ t.Parallel()
+ got := QueryFamily(NormalizedQuery{
+ Entities: []string{"pistons"},
+ NonEntityNormalized: "end of season",
+ })
+ if got != "end of season" {
+ t.Errorf("QueryFamily = %q, want %q", got, "end of season")
+ }
+}
+
+func TestResolveSlots_SingleEntity(t *testing.T) {
+ t.Parallel()
+ resolver := newStubResolver()
+ resolver.set("pistons", "Detroit Pistons")
+
+ p := Playbook{
+ EntitySlots: []string{"$TEAM"},
+ Steps: []PlaybookStep{{Cmd: "teams basketball nba {team.id}"}},
+ }
+ normalized := NormalizedQuery{
+ Entities: []string{"pistons"},
+ NonEntityNormalized: "end of season",
+ }
+
+ got := ResolveSlots(p, normalized, resolver)
+ if got == nil {
+ t.Fatal("ResolveSlots returned nil")
+ }
+ slot, ok := got["$TEAM"]
+ if !ok {
+ t.Fatalf("$TEAM slot missing; got %+v", got)
+ }
+ if slot["canonical"] != "Detroit Pistons" {
+ t.Errorf("canonical = %v, want Detroit Pistons", slot["canonical"])
+ }
+ if slot["token"] != "pistons" {
+ t.Errorf("token = %v, want pistons", slot["token"])
+ }
+}
+
+func TestResolveSlots_MultiEntity(t *testing.T) {
+ t.Parallel()
+ resolver := newStubResolver()
+ resolver.set("mariners", "Seattle Mariners")
+ resolver.set("mets", "New York Mets")
+
+ p := Playbook{
+ EntitySlots: []string{"$HOME", "$AWAY"},
+ Steps: []PlaybookStep{{Cmd: "h2h {team.abbr.home} {team.abbr.away}"}},
+ }
+ // Hand-build NormalizedQuery (sorted entities) so the test runs
+ // without depending on the canonical-resolver promotion path.
+ normalized := NormalizedQuery{
+ Entities: []string{"mariners", "mets"},
+ NonEntityNormalized: "tonight vs",
+ }
+
+ got := ResolveSlots(p, normalized, resolver)
+ homeSlot, ok := got["$HOME"]
+ if !ok {
+ t.Fatalf("$HOME slot missing; got %+v", got)
+ }
+ awaySlot, ok := got["$AWAY"]
+ if !ok {
+ t.Fatalf("$AWAY slot missing; got %+v", got)
+ }
+ // Both slots must be bound to distinct canonical entities. Slots
+ // claim tokens in normalized-sorted order: $HOME claims first
+ // available (mariners), $AWAY claims the next (mets).
+ if homeSlot["canonical"] == awaySlot["canonical"] {
+ t.Errorf("HOME and AWAY collapsed onto the same canonical: %+v / %+v", homeSlot, awaySlot)
+ }
+ if homeSlot["token"] == awaySlot["token"] {
+ t.Errorf("HOME and AWAY claimed the same token: %+v / %+v", homeSlot, awaySlot)
+ }
+}
+
+func TestResolveSlots_UnresolvableSkipped(t *testing.T) {
+ t.Parallel()
+ resolver := newStubResolver()
+ // No registrations.
+
+ p := Playbook{
+ EntitySlots: []string{"$THING"},
+ Steps: []PlaybookStep{{Cmd: "x"}},
+ }
+ normalized := NormalizedQuery{
+ Entities: []string{"weatherman"},
+ NonEntityNormalized: "how doing is",
+ }
+ got := ResolveSlots(p, normalized, resolver)
+ if _, ok := got["$THING"]; ok {
+ t.Errorf("unresolvable slot should be absent; got %+v", got)
+ }
+}
+
+func TestResolveSlots_EmptySlots(t *testing.T) {
+ t.Parallel()
+ resolver := newStubResolver()
+ p := Playbook{Steps: []PlaybookStep{{Cmd: "x"}}}
+ got := ResolveSlots(p, NormalizedQuery{Entities: []string{"anything"}}, resolver)
+ if got != nil {
+ t.Errorf("empty entity_slots should yield nil map, got %+v", got)
+ }
+}
+
+func TestResolveSlots_NilResolver(t *testing.T) {
+ t.Parallel()
+ p := Playbook{
+ EntitySlots: []string{"$X"},
+ Steps: []PlaybookStep{{Cmd: "x"}},
+ }
+ got := ResolveSlots(p, NormalizedQuery{Entities: []string{"anything"}}, nil)
+ if got != nil {
+ t.Errorf("nil resolver should yield nil, got %+v", got)
+ }
+}
+
+// TestResolveSlots_OnlyConsidersEntities guards the Greptile finding
+// on PR #851 round 3: ResolveSlots used to include non-entity tokens
+// in the candidate pool. If a token classified as non-entity happens
+// to resolve via entity_lookups (a secondary-alias collision the
+// extractor didn't promote), the old code would still let it win a
+// slot binding meant for a real entity. The fix restricts the pool
+// to normalized.Entities so the slot stays unbound rather than
+// silently grabbing the wrong token.
+//
+// To exercise the contract without depending on PromoteEntities'
+// classification logic, we construct the normalized query directly:
+// "boston" is in Entities, "ppg" is only in NonEntityNormalized, and
+// the resolver can resolve BOTH. Pre-fix: "ppg" would have won
+// because the candidate pool included non-entity tokens. Post-fix:
+// "boston" is the only candidate.
+func TestResolveSlots_OnlyConsidersEntities(t *testing.T) {
+ t.Parallel()
+ resolver := newStubResolver()
+ resolver.set("boston", "Boston Celtics")
+ // Register "ppg" as a secondary alias of a different canonical so
+ // the resolver returns a hit for it.
+ resolver.set("ppg", "PointsPerGame")
+
+ p := Playbook{
+ EntitySlots: []string{"$TEAM"},
+ Steps: []PlaybookStep{{Cmd: "leaders {team.abbr}"}},
+ }
+ // Hand-build the normalized query so "ppg" stays in
+ // NonEntityNormalized (the test of the fix's contract).
+ normalized := NormalizedQuery{
+ Entities: []string{"boston"},
+ NonEntityNormalized: "leads ppg who",
+ }
+ got := ResolveSlots(p, normalized, resolver)
+
+ slot, ok := got["$TEAM"]
+ if !ok {
+ t.Fatalf("$TEAM slot missing; got %+v", got)
+ }
+ if slot["token"] == "ppg" || slot["canonical"] == "PointsPerGame" {
+ t.Errorf("non-entity token 'ppg' wrongly won the $TEAM slot; slot=%+v", slot)
+ }
+ if slot["canonical"] != "Boston Celtics" {
+ t.Errorf("$TEAM canonical = %v, want Boston Celtics", slot["canonical"])
+ }
+ if slot["token"] != "boston" {
+ t.Errorf("$TEAM token = %v, want boston", slot["token"])
+ }
+}
+
+// TestResolveSlots_DeterministicMultiCanonical confirms that when a
+// resolver returns multiple canonicals for one token, the first is
+// chosen deterministically (sorted), matching ESPN's contract.
+func TestResolveSlots_DeterministicMultiCanonical(t *testing.T) {
+ t.Parallel()
+ resolver := newStubResolver()
+ // Resolver returns canonicals in non-sorted order.
+ resolver.set("ambiguous", "Zebra", "Alpha", "Mango")
+
+ p := Playbook{
+ EntitySlots: []string{"$X"},
+ Steps: []PlaybookStep{{Cmd: "x"}},
+ }
+ normalized := NormalizedQuery{
+ Entities: []string{"ambiguous"},
+ NonEntityNormalized: "",
+ }
+
+ got := ResolveSlots(p, normalized, resolver)
+ slot, ok := got["$X"]
+ if !ok {
+ t.Fatalf("$X slot missing; got %+v", got)
+ }
+ if slot["canonical"] != "Alpha" {
+ t.Errorf("expected deterministic first canonical 'Alpha', got %v", slot["canonical"])
+ }
+}
+
+// openPlaybookLookupDB creates a throwaway SQLite database with only the
+// learning_playbooks table — the single table the dual-key lookup
+// touches. Mirrors the v3 store migration shape.
+func openPlaybookLookupDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "playbooks.db"))
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := db.Exec(`CREATE TABLE learning_playbooks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_family TEXT NOT NULL UNIQUE,
+ playbook_json TEXT,
+ notes_text TEXT,
+ source TEXT NOT NULL,
+ confidence INTEGER NOT NULL DEFAULT 2,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME
+ )`); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ return db
+}
+
+func seedLookupPlaybookRow(t *testing.T, db *sql.DB, family, notes string) {
+ t.Helper()
+ if _, err := db.Exec(
+ `INSERT INTO learning_playbooks (query_family, notes_text, source, confidence)
+ VALUES (?, ?, 'taught', 2)`,
+ family, notes,
+ ); err != nil {
+ t.Fatalf("seed playbook row: %v", err)
+ }
+}
+
+func storedFamilyKeys(t *testing.T, db *sql.DB) []string {
+ t.Helper()
+ rows, err := db.Query(`SELECT query_family FROM learning_playbooks ORDER BY id`)
+ if err != nil {
+ t.Fatalf("list families: %v", err)
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var f string
+ if err := rows.Scan(&f); err != nil {
+ t.Fatalf("scan family: %v", err)
+ }
+ out = append(out, f)
+ }
+ return out
+}
+
+func TestLookupPlaybookByFamily_FoldedKeyExactHit(t *testing.T) {
+ t.Parallel()
+ db := openPlaybookLookupDB(t)
+ cfg := entities.NewConfig()
+
+ // Row stored post-fold under the canonical family.
+ folded := QueryFamily(Normalize("why did Alpha win yesterday", cfg))
+ seedLookupPlaybookRow(t, db, folded, "canonical-notes")
+
+ // The variant phrasing folds to the same family and hits directly.
+ q := Normalize("why did Alpha win last night", cfg)
+ row, found, err := LookupPlaybookByFamily(context.Background(), db, cfg,
+ QueryFamily(q), QueryFamilyPreFold(q))
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ if !found {
+ t.Fatal("expected folded-key hit")
+ }
+ if row.QueryFamily != folded || row.NotesText != "canonical-notes" {
+ t.Errorf("row = %+v, want family %q", row, folded)
+ }
+}
+
+func TestLookupPlaybookByFamily_PreFoldRowFoundAndRekeyed(t *testing.T) {
+ t.Parallel()
+ db := openPlaybookLookupDB(t)
+ cfg := entities.NewConfig()
+
+ // Row stored PRE-fold under the non-canonical variant's family
+ // ("last night" shape), as an older CLI build would have keyed it.
+ preFold := QueryFamily(NormalizeUnfolded("why did Alpha win last night", cfg))
+ folded := QueryFamily(Normalize("why did Alpha win last night", cfg))
+ if preFold == folded {
+ t.Fatalf("test setup: pre-fold %q must differ from folded %q", preFold, folded)
+ }
+ seedLookupPlaybookRow(t, db, preFold, "stranded-notes")
+
+ // A "yesterday" query must still find it — and heal the key in place.
+ q := Normalize("why did Alpha win yesterday", cfg)
+ row, found, err := LookupPlaybookByFamily(context.Background(), db, cfg,
+ QueryFamily(q), QueryFamilyPreFold(q))
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ if !found {
+ t.Fatal("pre-fold row was stranded: folded query missed it")
+ }
+ if row.NotesText != "stranded-notes" {
+ t.Errorf("row = %+v, want the stranded row", row)
+ }
+ got := storedFamilyKeys(t, db)
+ if len(got) != 1 || got[0] != folded {
+ t.Errorf("stored family = %v, want rekeyed to [%q]", got, folded)
+ }
+}
+
+func TestLookupPlaybookByFamily_RawKeyFallbackRekeys(t *testing.T) {
+ t.Parallel()
+ db := openPlaybookLookupDB(t)
+ cfg := entities.NewConfig()
+
+ // Row stored pre-fold; the query arrives in the SAME non-canonical
+ // phrasing, so its pre-fold family equals the stored key exactly.
+ // The raw-key fallback should hit without needing the full scan,
+ // then rekey to the folded form.
+ q := Normalize("why did Alpha win last night", cfg)
+ raw := QueryFamilyPreFold(q)
+ folded := QueryFamily(q)
+ seedLookupPlaybookRow(t, db, raw, "raw-keyed-notes")
+
+ row, found, err := LookupPlaybookByFamily(context.Background(), db, cfg, folded, raw)
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ if !found || row.NotesText != "raw-keyed-notes" {
+ t.Fatalf("raw-key fallback missed: found=%v row=%+v", found, row)
+ }
+ got := storedFamilyKeys(t, db)
+ if len(got) != 1 || got[0] != folded {
+ t.Errorf("stored family = %v, want rekeyed to [%q]", got, folded)
+ }
+}
+
+func TestLookupPlaybookByFamily_RekeyIsIdempotent(t *testing.T) {
+ t.Parallel()
+ db := openPlaybookLookupDB(t)
+ cfg := entities.NewConfig()
+
+ preFold := QueryFamily(NormalizeUnfolded("why did Alpha win last night", cfg))
+ seedLookupPlaybookRow(t, db, preFold, "notes")
+
+ q := Normalize("why did Alpha win yesterday", cfg)
+ folded := QueryFamily(q)
+ for i := 0; i < 2; i++ {
+ row, found, err := LookupPlaybookByFamily(context.Background(), db, cfg,
+ folded, QueryFamilyPreFold(q))
+ if err != nil {
+ t.Fatalf("lookup %d: %v", i+1, err)
+ }
+ if !found || row.QueryFamily != folded {
+ t.Fatalf("lookup %d: found=%v family=%q, want %q", i+1, found, row.QueryFamily, folded)
+ }
+ }
+ got := storedFamilyKeys(t, db)
+ if len(got) != 1 || got[0] != folded {
+ t.Errorf("second lookup must be a no-op on keys; got %v", got)
+ }
+}
+
+func TestLookupPlaybookByFamily_DayBoundaryNeverMatches(t *testing.T) {
+ t.Parallel()
+ db := openPlaybookLookupDB(t)
+ cfg := entities.NewConfig()
+
+ // A "tonight" family must never be reachable from a "yesterday" query.
+ tonightFam := QueryFamily(Normalize("who wins tonight", cfg))
+ seedLookupPlaybookRow(t, db, tonightFam, "tonight-notes")
+
+ q := Normalize("who wins yesterday", cfg)
+ _, found, err := LookupPlaybookByFamily(context.Background(), db, cfg,
+ QueryFamily(q), QueryFamilyPreFold(q))
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ if found {
+ t.Fatal("tonight family must not fold across the day boundary to yesterday")
+ }
+ if got := storedFamilyKeys(t, db); len(got) != 1 || got[0] != tonightFam {
+ t.Errorf("tonight row must stay untouched; got %v", got)
+ }
+}
+
+func TestLookupPlaybookByFamily_EmptyFamilyMisses(t *testing.T) {
+ t.Parallel()
+ db := openPlaybookLookupDB(t)
+ cfg := entities.NewConfig()
+ _, found, err := LookupPlaybookByFamily(context.Background(), db, cfg, "", "")
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ if found {
+ t.Fatal("empty family must not match anything")
+ }
+}
+
+func TestFoldFamilyKey_NonAdjacentVariantTokens(t *testing.T) {
+ t.Parallel()
+ cfg := entities.NewConfig()
+ // Sorted family keys can separate a multiword variant's tokens;
+ // bag folding still collapses them and re-sorts.
+ if got := FoldFamilyKey("last mood night", cfg); got != "mood yesterday" {
+ t.Errorf("FoldFamilyKey = %q, want %q", got, "mood yesterday")
+ }
+ // Already-canonical keys are unchanged.
+ if got := FoldFamilyKey("mood yesterday", cfg); got != "mood yesterday" {
+ t.Errorf("FoldFamilyKey (canonical) = %q, want unchanged", got)
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/preseed.go b/library/productivity/human-goat/internal/learn/preseed.go
new file mode 100644
index 0000000000..ddfb5c8856
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/preseed.go
@@ -0,0 +1,250 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// SourcePreseed is the provenance tag preseed writes into
+// search_learnings.source.
+const SourcePreseed = "preseed"
+
+// preseedConfidenceFloor matches the floor in Teach so a fresh
+// preseed row trips the SKILL.md skip threshold on its first
+// observation.
+const preseedConfidenceFloor = 2
+
+// DefaultPreseedRowCap caps per-Run total rows to keep a runaway
+// scanner from filling the table when sync surfaces an unexpectedly
+// large family. Overridable via PRESEED_ROW_CAP; 0 disables the cap.
+const DefaultPreseedRowCap = 10000
+
+// PreseedRow is the unit a scanner emits. The driver translates each
+// row into one INSERT into search_learnings (subject to the existence
+// pre-check).
+type PreseedRow struct {
+ QueryPattern string
+ ResourceID string
+ ResourceType string
+ Venue string
+ Entities []string
+ Source string
+}
+
+// ScannerFn is the per-CLI scanner contract. Scanners query the
+// just-synced corpus from db and return PreseedRow values.
+type ScannerFn func(ctx context.Context, db *sql.DB) ([]PreseedRow, error)
+
+var (
+ scannerMu sync.RWMutex
+ scannerRegistry = map[string]ScannerFn{}
+)
+
+// Register adds a scanner to the package-level registry. Safe for
+// concurrent registration. Last writer wins on key collision; callers
+// should use a distinct key per scanner. The generator template ships
+// the registry empty — consumer CLIs add scanners in their own
+// command init.
+func Register(key string, fn ScannerFn) {
+ if fn == nil || key == "" {
+ return
+ }
+ scannerMu.Lock()
+ defer scannerMu.Unlock()
+ scannerRegistry[key] = fn
+}
+
+// ResetScannersForTest clears the registry. Test-only helper.
+func ResetScannersForTest() {
+ scannerMu.Lock()
+ defer scannerMu.Unlock()
+ scannerRegistry = map[string]ScannerFn{}
+}
+
+// RegisterFromConfig is a convenience for declarative wiring: the
+// consumer CLI builds a map of key -> ScannerFn at init time and
+// passes it in. Equivalent to a loop of Register calls.
+func RegisterFromConfig(scanners map[string]ScannerFn) {
+ for k, fn := range scanners {
+ Register(k, fn)
+ }
+}
+
+// RunOpts tunes a Run invocation.
+type RunOpts struct {
+ // RowCap overrides DefaultPreseedRowCap. Negative means "use env
+ // or default"; zero means "no cap."
+ RowCap int
+
+ // Config is the per-CLI entity extractor config. When non-nil, the
+ // preseed driver computes Entities for any PreseedRow that didn't
+ // carry one. When nil, the row's Entities field is used as-is.
+ Config *cfgRef
+}
+
+// cfgRef is an opaque holder so RunOpts can carry a config without
+// importing entities (avoiding a tight coupling in the public API).
+// In practice the consumer passes Normalize-derived entities directly
+// in the PreseedRow, so this stays unset.
+type cfgRef struct{}
+
+// Run iterates every registered scanner, collects PreseedRow values,
+// and inserts each into search_learnings via direct SQL. Returns the
+// total count of rows actually inserted. Errors from individual
+// scanners are aggregated but do not abort the run.
+//
+// PRESEED_DISABLED (any non-empty value) skips the entire run.
+func Run(ctx context.Context, db *sql.DB) (int, error) {
+ return RunWith(ctx, db, RunOpts{RowCap: -1})
+}
+
+// RunWith is the explicit-options variant.
+func RunWith(ctx context.Context, db *sql.DB, opts RunOpts) (int, error) {
+ if os.Getenv("PRESEED_DISABLED") != "" {
+ return 0, nil
+ }
+ if db == nil {
+ return 0, errors.New("preseed.Run: db is nil")
+ }
+ rowCap := resolveRowCap(opts.RowCap)
+
+ scannerMu.RLock()
+ scanners := make([]ScannerFn, 0, len(scannerRegistry))
+ keys := make([]string, 0, len(scannerRegistry))
+ for k, fn := range scannerRegistry {
+ scanners = append(scanners, fn)
+ keys = append(keys, k)
+ }
+ scannerMu.RUnlock()
+
+ if len(scanners) == 0 {
+ return 0, nil
+ }
+
+ var collected []PreseedRow
+ var scannerErrs []error
+ for i, fn := range scanners {
+ if err := ctx.Err(); err != nil {
+ return 0, err
+ }
+ rows, err := fn(ctx, db)
+ if err != nil {
+ scannerErrs = append(scannerErrs, fmt.Errorf("preseed scanner %q: %w", keys[i], err))
+ continue
+ }
+ collected = append(collected, rows...)
+ }
+ collected = dedupRows(collected)
+ if rowCap > 0 && len(collected) > rowCap {
+ collected = collected[:rowCap]
+ }
+
+ inserted := 0
+ for _, row := range collected {
+ if err := ctx.Err(); err != nil {
+ break
+ }
+ created, err := insertOne(ctx, db, row)
+ if err != nil {
+ scannerErrs = append(scannerErrs, fmt.Errorf("preseed upsert %s/%s: %w", row.ResourceType, row.ResourceID, err))
+ continue
+ }
+ if created {
+ inserted++
+ }
+ }
+ if len(scannerErrs) > 0 {
+ return inserted, errors.Join(scannerErrs...)
+ }
+ return inserted, nil
+}
+
+// insertOne is the per-row write path. Skips when a row exists for
+// the same (query_pattern, resource_id, action); preseed is a
+// pure-insert operation, never a confidence bump. The unique index
+// idx_learn_unique enforces this on the DB side too.
+func insertOne(ctx context.Context, db *sql.DB, row PreseedRow) (bool, error) {
+ if row.ResourceID == "" || row.QueryPattern == "" || row.ResourceType == "" {
+ return false, nil
+ }
+ source := row.Source
+ if source == "" {
+ source = SourcePreseed
+ }
+ // Existence check: any row matching (query_pattern, resource_id,
+ // "boost") blocks the insert. Preseed never overwrites or bumps;
+ // the recall path will read whatever is already there.
+ var existingID int64
+ err := db.QueryRowContext(ctx,
+ `SELECT id FROM search_learnings WHERE query_pattern = ? AND resource_id = ? AND action = 'boost'`,
+ row.QueryPattern, row.ResourceID,
+ ).Scan(&existingID)
+ if err == nil {
+ return false, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return false, fmt.Errorf("preseed existence check: %w", err)
+ }
+
+ entitiesSlice := row.Entities
+ if entitiesSlice == nil {
+ entitiesSlice = []string{}
+ }
+ entitiesJSON, err := json.Marshal(entitiesSlice)
+ if err != nil {
+ return false, fmt.Errorf("preseed marshal entities: %w", err)
+ }
+
+ now := time.Now().UTC()
+ _, err = db.ExecContext(ctx,
+ `INSERT INTO search_learnings
+ (query_pattern, query_entities, venue, resource_type, resource_id,
+ action, source, confidence, created_at, last_observed_at)
+ VALUES (?, ?, ?, ?, ?, 'boost', ?, ?, ?, ?)`,
+ row.QueryPattern, string(entitiesJSON), row.Venue, row.ResourceType, row.ResourceID,
+ source, preseedConfidenceFloor, now, now,
+ )
+ if err != nil {
+ return false, fmt.Errorf("preseed insert: %w", err)
+ }
+ return true, nil
+}
+
+func dedupRows(in []PreseedRow) []PreseedRow {
+ if len(in) == 0 {
+ return in
+ }
+ seen := make(map[string]struct{}, len(in))
+ out := make([]PreseedRow, 0, len(in))
+ for _, r := range in {
+ key := r.QueryPattern + "|" + r.ResourceID
+ if _, dup := seen[key]; dup {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, r)
+ }
+ return out
+}
+
+func resolveRowCap(opts int) int {
+ if opts >= 0 {
+ return opts
+ }
+ if v := os.Getenv("PRESEED_ROW_CAP"); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ return n
+ }
+ }
+ return DefaultPreseedRowCap
+}
diff --git a/library/productivity/human-goat/internal/learn/preseed_test.go b/library/productivity/human-goat/internal/learn/preseed_test.go
new file mode 100644
index 0000000000..eaeeda39e1
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/preseed_test.go
@@ -0,0 +1,226 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "testing"
+)
+
+func staticScanner(rows []PreseedRow, err error) ScannerFn {
+ return func(_ context.Context, _ *sql.DB) ([]PreseedRow, error) {
+ return rows, err
+ }
+}
+
+func TestPreseedRun_EmptyRegistryNoop(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ n, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if n != 0 {
+ t.Errorf("empty registry: want 0, got %d", n)
+ }
+}
+
+func TestPreseedRun_TwoScannersInsertAll(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ Register("widgets", staticScanner([]PreseedRow{
+ {QueryPattern: "alpha widget", ResourceID: "PREFIX-AL", ResourceType: "widgets", Entities: []string{"Alpha"}},
+ {QueryPattern: "bravo widget", ResourceID: "PREFIX-BR", ResourceType: "widgets", Entities: []string{"Bravo"}},
+ }, nil))
+ Register("items", staticScanner([]PreseedRow{
+ {QueryPattern: "charlie thing", ResourceID: "ITEM-CH", ResourceType: "items", Entities: []string{"Charlie"}},
+ }, nil))
+ n, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if n != 3 {
+ t.Errorf("Run inserted %d, want 3", n)
+ }
+ var count int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM search_learnings`).Scan(&count); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if count != 3 {
+ t.Errorf("learnings count = %d, want 3", count)
+ }
+}
+
+func TestPreseedRun_DedupesWithinBatch(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ Register("widgets", staticScanner([]PreseedRow{
+ {QueryPattern: "alpha widget", ResourceID: "PREFIX-AL", ResourceType: "widgets"},
+ {QueryPattern: "alpha widget", ResourceID: "PREFIX-AL", ResourceType: "widgets"},
+ {QueryPattern: "bravo widget", ResourceID: "PREFIX-BR", ResourceType: "widgets"},
+ }, nil))
+ n, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if n != 2 {
+ t.Errorf("Run inserted %d, want 2 (dup collapsed)", n)
+ }
+}
+
+func TestPreseedRun_RerunIsNoop(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ Register("widgets", staticScanner([]PreseedRow{
+ {QueryPattern: "alpha widget", ResourceID: "PREFIX-AL", ResourceType: "widgets"},
+ }, nil))
+ n1, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("first Run: %v", err)
+ }
+ if n1 != 1 {
+ t.Errorf("first inserted = %d, want 1", n1)
+ }
+ n2, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("second Run: %v", err)
+ }
+ if n2 != 0 {
+ t.Errorf("second inserted = %d, want 0 (no-op)", n2)
+ }
+}
+
+func TestPreseedRun_TaughtRowPreserved(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ // User taught the row first at confidence=3.
+ seedLearning(t, db, "alpha widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+ Register("widgets", staticScanner([]PreseedRow{
+ {QueryPattern: "alpha widget", ResourceID: "PREFIX-AL", ResourceType: "widgets"},
+ }, nil))
+ n, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if n != 0 {
+ t.Errorf("preseed inserted %d over a taught row, want 0", n)
+ }
+ var source string
+ var confidence int
+ if err := db.QueryRow(`SELECT source, confidence FROM search_learnings`).Scan(&source, &confidence); err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if source != "taught" {
+ t.Errorf("source = %q, want taught (preseed must not overwrite)", source)
+ }
+ if confidence != 3 {
+ t.Errorf("confidence = %d, want 3 (preseed must not bump)", confidence)
+ }
+}
+
+func TestPreseedRun_ScannerErrorAggregatedNotFatal(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ scannerErr := errors.New("synthetic")
+ Register("broken", staticScanner(nil, scannerErr))
+ Register("widgets", staticScanner([]PreseedRow{
+ {QueryPattern: "alpha widget", ResourceID: "PREFIX-AL", ResourceType: "widgets"},
+ }, nil))
+ n, err := Run(context.Background(), db)
+ if err == nil {
+ t.Fatalf("expected aggregated error, got nil")
+ }
+ if !errors.Is(err, scannerErr) {
+ t.Errorf("error chain missing scanner error; got %v", err)
+ }
+ if n != 1 {
+ t.Errorf("inserted = %d, want 1 (scanner error must not block sibling)", n)
+ }
+}
+
+func TestPreseedRun_DisabledByEnv(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ called := false
+ Register("widgets", func(_ context.Context, _ *sql.DB) ([]PreseedRow, error) {
+ called = true
+ return []PreseedRow{{QueryPattern: "alpha widget", ResourceID: "PREFIX-AL", ResourceType: "widgets"}}, nil
+ })
+ t.Setenv("PRESEED_DISABLED", "1")
+ n, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if n != 0 {
+ t.Errorf("inserted = %d, want 0", n)
+ }
+ if called {
+ t.Errorf("scanner should not run with PRESEED_DISABLED set")
+ }
+}
+
+func TestPreseedRun_RowCapTruncates(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ rows := make([]PreseedRow, 5)
+ for i := range rows {
+ rows[i] = PreseedRow{
+ QueryPattern: "pattern" + string(rune('A'+i)),
+ ResourceID: "R" + string(rune('A'+i)),
+ ResourceType: "widgets",
+ }
+ }
+ Register("widgets", staticScanner(rows, nil))
+ n, err := RunWith(context.Background(), db, RunOpts{RowCap: 3})
+ if err != nil {
+ t.Fatalf("RunWith: %v", err)
+ }
+ if n != 3 {
+ t.Errorf("cap=3: inserted %d, want 3", n)
+ }
+}
+
+func TestPreseedRun_EmptyResourceIDSkipped(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ Register("widgets", staticScanner([]PreseedRow{
+ {QueryPattern: "alpha widget", ResourceID: "", ResourceType: "widgets"},
+ {QueryPattern: "bravo widget", ResourceID: "PREFIX-BR", ResourceType: "widgets"},
+ }, nil))
+ n, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("inserted = %d, want 1 (empty id should skip)", n)
+ }
+}
+
+func TestRegisterFromConfig_BulkRegister(t *testing.T) {
+ ResetScannersForTest()
+ t.Cleanup(ResetScannersForTest)
+ db := openRecallTestDB(t)
+ RegisterFromConfig(map[string]ScannerFn{
+ "widgets": staticScanner([]PreseedRow{{QueryPattern: "alpha widget", ResourceID: "PREFIX-AL", ResourceType: "widgets"}}, nil),
+ "items": staticScanner([]PreseedRow{{QueryPattern: "bravo item", ResourceID: "ITEM-BR", ResourceType: "items"}}, nil),
+ })
+ n, err := Run(context.Background(), db)
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if n != 2 {
+ t.Errorf("inserted %d, want 2", n)
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/promote.go b/library/productivity/human-goat/internal/learn/promote.go
new file mode 100644
index 0000000000..0b3c544afa
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/promote.go
@@ -0,0 +1,146 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+)
+
+// PromoteEntities walks the non-entity tokens of normalized and
+// promotes any whose lowercased form has a row in entity_lookups
+// (resolved via r) into Entities. The returned NormalizedQuery
+// carries the expanded entity set and a recomputed
+// NonEntityNormalized from the remaining tokens.
+//
+// Shared between recall and teach so a query like
+// "what are the odds usa wins" stores
+// query_entities=["usa"] at teach time (matching what recall
+// promotes), instead of leaving the column null because the
+// capitalization-based extractor missed the lowercase entity.
+//
+// A nil resolver or an empty NonEntityNormalized returns normalized
+// unchanged.
+//
+// The EntityResolver interface this consumes is declared in
+// playbooks.go alongside the slot-resolution helpers that share it,
+// so a single interface definition serves both teach-time symmetry
+// and recall-time playbook binding.
+func PromoteEntities(normalized NormalizedQuery, r EntityResolver) NormalizedQuery {
+ if r == nil {
+ return normalized
+ }
+ tokens := strings.Fields(normalized.NonEntityNormalized)
+ if len(tokens) == 0 {
+ return normalized
+ }
+ kept := make([]string, 0, len(tokens))
+ promoted := append([]string(nil), normalized.Entities...)
+ added := false
+ for _, tok := range tokens {
+ if cans := r.Resolve(tok); len(cans) > 0 {
+ promoted = append(promoted, tok)
+ added = true
+ continue
+ }
+ kept = append(kept, tok)
+ }
+ if !added {
+ return normalized
+ }
+ return NormalizedQuery{
+ Original: normalized.Original,
+ Entities: promoted,
+ Tickers: normalized.Tickers,
+ NonEntityNormalized: strings.Join(kept, " "),
+ }
+}
+
+// CanonicalResolver looks up entities in the entity_lookups table to
+// find their canonical(s). Caches per-call so a query with N distinct
+// entities issues at most N SQL lookups regardless of how many
+// candidate rows the row loop walks.
+//
+// Implements the EntityResolver interface so the shared
+// PromoteEntities helper runs at both teach and recall time without
+// duplicating the resolver shape.
+type CanonicalResolver struct {
+ ctx context.Context
+ db *sql.DB
+ cache map[string][]string // lowercased entity -> distinct canonicals
+}
+
+// NewCanonicalResolver constructs a per-call canonical resolver.
+// Cache is per-instance so concurrent recall/teach calls don't share
+// stale lookups.
+func NewCanonicalResolver(ctx context.Context, db *sql.DB) *CanonicalResolver {
+ return &CanonicalResolver{ctx: ctx, db: db, cache: make(map[string][]string)}
+}
+
+// Resolve returns the canonical(s) for a single entity. A single token
+// may map to multiple canonicals when the same alias exists across
+// kinds. Empty slice when the entity has no row in entity_lookups.
+//
+// Error handling is deliberately non-caching: a query error, scan
+// error, or rows.Err() failure returns nil WITHOUT populating the
+// cache. A subsequent call to Resolve for the same token retries the
+// SQL instead of returning a stale partial slice. This guards against
+// a transient DB failure (cancelled context, temporary lock, schema
+// not yet created) pinning a truncated canonical list for the rest of
+// the resolver's lifetime.
+func (r *CanonicalResolver) Resolve(entity string) []string {
+ key := strings.ToLower(strings.TrimSpace(entity))
+ if key == "" {
+ return nil
+ }
+ if cached, ok := r.cache[key]; ok {
+ return cached
+ }
+ // Match against both `value` (alias side) and `canonical` (so an
+ // already-canonical query resolves to itself). DISTINCT collapses
+ // multi-kind duplicates of the same canonical.
+ rows, err := r.db.QueryContext(r.ctx,
+ `SELECT DISTINCT canonical FROM entity_lookups
+ WHERE LOWER(value) = ? OR LOWER(canonical) = ?`,
+ key, key)
+ if err != nil {
+ // Don't cache on query error -- a transient failure (cancelled
+ // context, missing table at startup) must not pin nil for the
+ // rest of this resolver's life. Subsequent calls retry.
+ return nil
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var c string
+ if err := rows.Scan(&c); err != nil {
+ // Don't cache partial results: a scan failure mid-loop
+ // would otherwise pin a truncated canonical list for
+ // every subsequent Resolve call, silently suppressing
+ // real canonicals.
+ return nil
+ }
+ out = append(out, c)
+ }
+ if err := rows.Err(); err != nil {
+ // Same reason -- incomplete iteration must not be cached.
+ return nil
+ }
+ r.cache[key] = out
+ return out
+}
+
+// ResolveSet expands a slice of entities into a set of canonicals.
+// Entries that don't resolve are dropped silently -- they simply don't
+// contribute to the cross-alias matching score.
+func (r *CanonicalResolver) ResolveSet(entities []string) map[string]struct{} {
+ out := make(map[string]struct{})
+ for _, e := range entities {
+ for _, c := range r.Resolve(e) {
+ out[c] = struct{}{}
+ }
+ }
+ return out
+}
diff --git a/library/productivity/human-goat/internal/learn/promote_test.go b/library/productivity/human-goat/internal/learn/promote_test.go
new file mode 100644
index 0000000000..899f4eaa45
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/promote_test.go
@@ -0,0 +1,385 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strings"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+// openCanonicalTestDB opens a fresh sqlite DB with the entity_lookups
+// table for resolver tests. Schema matches what
+// learn/lookups/store.go writes.
+func openCanonicalTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "canonical.db")
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := db.Exec(`CREATE TABLE entity_lookups (
+ kind TEXT NOT NULL,
+ canonical TEXT NOT NULL,
+ value TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'seeded',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (kind, canonical, value)
+ )`); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ return db
+}
+
+// openMissingTableDB opens a sqlite DB WITHOUT the entity_lookups
+// table. The first Resolve() call will hit a "no such table" error.
+// The returned `fix` closure creates and seeds the table so a follow-
+// up Resolve() call can succeed -- the test of the cache-poison fix
+// is whether the second call retries the SQL or returns cached nil.
+func openMissingTableDB(t *testing.T) (*sql.DB, func()) {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "missing.db")
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ fix := func() {
+ if _, err := db.Exec(`CREATE TABLE entity_lookups (
+ kind TEXT NOT NULL,
+ canonical TEXT NOT NULL,
+ value TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'seeded',
+ PRIMARY KEY (kind, canonical, value)
+ )`); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, err := db.Exec(
+ `INSERT INTO entity_lookups (kind, canonical, value, source)
+ VALUES ('demo', 'United States', 'US', 'seeded')`,
+ ); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ }
+ return db, fix
+}
+
+// TestCanonicalResolver_ScanError verifies the Greptile-found
+// cache-poison guard. A failure during row iteration (here simulated
+// via a cancelled context that errors before any row materializes)
+// must NOT cache the truncated result. A subsequent Resolve call on
+// a healthy resolver returns the real canonical.
+func TestCanonicalResolver_ScanError(t *testing.T) {
+ t.Parallel()
+ db := openCanonicalTestDB(t)
+ if _, err := db.Exec(
+ `INSERT INTO entity_lookups (kind, canonical, value, source)
+ VALUES ('demo', 'United States', 'US', 'seeded')`,
+ ); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ // Cancelled context forces the DB to error before rows arrive.
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ resolver := NewCanonicalResolver(ctx, db)
+ got := resolver.Resolve("US")
+ if got != nil {
+ t.Fatalf("cancelled-context Resolve should return nil; got %v", got)
+ }
+
+ // A fresh resolver against the same healthy DB must see the
+ // canonical -- proving the error path didn't poison the table or
+ // leak shared state.
+ healthy := NewCanonicalResolver(context.Background(), db)
+ got = healthy.Resolve("US")
+ if len(got) == 0 {
+ t.Errorf("healthy resolver should return canonical for 'US'; got %v", got)
+ }
+}
+
+// TestCanonicalResolver_ErrorRetry verifies the same cache-poison
+// guard on a single resolver instance: a first Resolve() that errors
+// (table missing) must NOT cache nil. After the table is created,
+// the second Resolve() call must retry the SQL and surface the
+// canonical.
+func TestCanonicalResolver_ErrorRetry(t *testing.T) {
+ t.Parallel()
+ db, fix := openMissingTableDB(t)
+
+ resolver := NewCanonicalResolver(context.Background(), db)
+ // First call: table doesn't exist -- query errors, returns nil.
+ if got := resolver.Resolve("US"); got != nil {
+ t.Fatalf("first Resolve before table exists should return nil; got %v", got)
+ }
+ // Fix the table.
+ fix()
+ // Second call: must retry the SQL, not return cached nil.
+ if got := resolver.Resolve("US"); len(got) == 0 {
+ t.Errorf("second Resolve after table healed should return canonical; got %v -- partial cache poison?", got)
+ }
+}
+
+// TestCanonicalResolver_HappyPath covers the basic Resolve and
+// ResolveSet contract against a seeded DB.
+func TestCanonicalResolver_HappyPath(t *testing.T) {
+ t.Parallel()
+ db := openCanonicalTestDB(t)
+ if _, err := db.Exec(
+ `INSERT INTO entity_lookups (kind, canonical, value, source)
+ VALUES ('demo', 'United States', 'US', 'seeded'),
+ ('demo', 'United Kingdom', 'GB', 'seeded')`,
+ ); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ resolver := NewCanonicalResolver(context.Background(), db)
+
+ // By value (alias side).
+ got := resolver.Resolve("US")
+ if len(got) != 1 || got[0] != "United States" {
+ t.Errorf("Resolve(US): want [United States], got %v", got)
+ }
+
+ // By canonical (self-resolution).
+ got = resolver.Resolve("united kingdom")
+ if len(got) != 1 || got[0] != "United Kingdom" {
+ t.Errorf("Resolve(united kingdom): want [United Kingdom], got %v", got)
+ }
+
+ // Unknown token returns empty.
+ if got := resolver.Resolve("nowhere"); len(got) != 0 {
+ t.Errorf("Resolve(nowhere): want empty, got %v", got)
+ }
+
+ // Empty/whitespace returns nil without SQL.
+ if got := resolver.Resolve(""); got != nil {
+ t.Errorf("Resolve(empty): want nil, got %v", got)
+ }
+
+ // ResolveSet collects canonicals from a slice.
+ set := resolver.ResolveSet([]string{"US", "GB", "nowhere"})
+ if _, ok := set["United States"]; !ok {
+ t.Errorf("ResolveSet missing United States; got %v", set)
+ }
+ if _, ok := set["United Kingdom"]; !ok {
+ t.Errorf("ResolveSet missing United Kingdom; got %v", set)
+ }
+ if _, ok := set["nowhere"]; ok {
+ t.Errorf("ResolveSet should drop unresolvable tokens; got %v", set)
+ }
+}
+
+// TestPromoteEntities_PromotesResolvableToken covers the canonical
+// happy path: a token whose lowercased form has a row in
+// entity_lookups (via the resolver) moves from NonEntityNormalized
+// into Entities.
+func TestPromoteEntities_PromotesResolvableToken(t *testing.T) {
+ t.Parallel()
+ resolver := newPromoteStubResolver()
+ resolver.set("usa", "United States")
+
+ normalized := NormalizedQuery{
+ Original: "what are the odds usa wins",
+ Entities: []string{},
+ NonEntityNormalized: "are odds the usa wins what",
+ }
+ got := PromoteEntities(normalized, resolver)
+ if !containsTok(got.Entities, "usa") {
+ t.Errorf("want 'usa' promoted into Entities, got %v", got.Entities)
+ }
+ for _, tok := range strings.Fields(got.NonEntityNormalized) {
+ if tok == "usa" {
+ t.Errorf("non-entity tokens should no longer contain 'usa'; got %q", got.NonEntityNormalized)
+ }
+ }
+}
+
+// TestPromoteEntities_NoMatch_LeavesUnchanged confirms that a query
+// with no resolver hits is returned identity-equal to the input --
+// the helper must not allocate spuriously.
+func TestPromoteEntities_NoMatch_LeavesUnchanged(t *testing.T) {
+ t.Parallel()
+ resolver := newPromoteStubResolver()
+ normalized := NormalizedQuery{
+ Original: "hello world foo bar",
+ Entities: []string{},
+ NonEntityNormalized: "bar foo hello world",
+ }
+ got := PromoteEntities(normalized, resolver)
+ if !reflect.DeepEqual(got, normalized) {
+ t.Errorf("no resolver hits should leave normalized unchanged; got %+v want %+v", got, normalized)
+ }
+}
+
+// TestPromoteEntities_NilResolver_Identity confirms a nil resolver
+// is a no-op.
+func TestPromoteEntities_NilResolver_Identity(t *testing.T) {
+ t.Parallel()
+ normalized := NormalizedQuery{
+ Original: "how are the teams doing",
+ Entities: []string{},
+ NonEntityNormalized: "are doing how teams the",
+ }
+ got := PromoteEntities(normalized, nil)
+ if !reflect.DeepEqual(got, normalized) {
+ t.Errorf("nil resolver should be identity; got %+v want %+v", got, normalized)
+ }
+}
+
+// TestPromoteEntities_EmptyQuery_NoOp confirms an empty
+// NonEntityNormalized short-circuits.
+func TestPromoteEntities_EmptyQuery_NoOp(t *testing.T) {
+ t.Parallel()
+ resolver := newPromoteStubResolver()
+ resolver.set("usa", "United States")
+ normalized := NormalizedQuery{
+ Original: "",
+ Entities: []string{},
+ NonEntityNormalized: "",
+ }
+ got := PromoteEntities(normalized, resolver)
+ if !reflect.DeepEqual(got, normalized) {
+ t.Errorf("empty query should be identity; got %+v want %+v", got, normalized)
+ }
+}
+
+// TestPromoteEntities_MultiEntity covers two tokens promoted in one
+// call.
+func TestPromoteEntities_MultiEntity(t *testing.T) {
+ t.Parallel()
+ resolver := newPromoteStubResolver()
+ resolver.set("usa", "United States")
+ resolver.set("uk", "United Kingdom")
+ normalized := NormalizedQuery{
+ Original: "usa vs uk tonight",
+ Entities: []string{},
+ NonEntityNormalized: "tonight uk usa vs",
+ }
+ got := PromoteEntities(normalized, resolver)
+ if !containsTok(got.Entities, "usa") || !containsTok(got.Entities, "uk") {
+ t.Errorf("want both 'usa' and 'uk' promoted, got %v", got.Entities)
+ }
+}
+
+// TestPromoteEntities_PreservesExistingEntities confirms entities
+// already in the slice survive a promotion pass.
+func TestPromoteEntities_PreservesExistingEntities(t *testing.T) {
+ t.Parallel()
+ resolver := newPromoteStubResolver()
+ resolver.set("usa", "United States")
+
+ normalized := NormalizedQuery{
+ Original: "how does Portugal play against usa",
+ Entities: []string{"Portugal"},
+ NonEntityNormalized: "against does how play the usa",
+ }
+ got := PromoteEntities(normalized, resolver)
+ if !containsTok(got.Entities, "Portugal") {
+ t.Errorf("Portugal should be preserved as entity; got %v", got.Entities)
+ }
+ if !containsTok(got.Entities, "usa") {
+ t.Errorf("usa should also be promoted; got %v", got.Entities)
+ }
+}
+
+// TestPromoteEntities_TeachRecallSymmetry locks in the round-trip
+// contract: PromoteEntities at teach time produces the same
+// query_entities slice as PromoteEntities at recall time would, so a
+// cross-alias fallback has stored entities to compare against.
+func TestPromoteEntities_TeachRecallSymmetry(t *testing.T) {
+ t.Parallel()
+ db := openCanonicalTestDB(t)
+ if _, err := db.Exec(
+ `INSERT INTO entity_lookups (kind, canonical, value, source)
+ VALUES ('demo', 'United States', 'US', 'seeded'),
+ ('demo', 'United States', 'usa', 'seeded')`,
+ ); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ normalized := NormalizedQuery{
+ Original: "odds usa wins",
+ Entities: []string{},
+ NonEntityNormalized: "odds usa wins",
+ }
+
+ teachResolver := NewCanonicalResolver(context.Background(), db)
+ teachNorm := PromoteEntities(normalized, teachResolver)
+
+ recallResolver := NewCanonicalResolver(context.Background(), db)
+ recallNorm := PromoteEntities(normalized, recallResolver)
+
+ if !reflect.DeepEqual(sortedCopy(teachNorm.Entities), sortedCopy(recallNorm.Entities)) {
+ t.Errorf("teach/recall Entities should be symmetric; teach=%v recall=%v",
+ teachNorm.Entities, recallNorm.Entities)
+ }
+ if teachNorm.NonEntityNormalized != recallNorm.NonEntityNormalized {
+ t.Errorf("teach/recall NonEntityNormalized should be symmetric; teach=%q recall=%q",
+ teachNorm.NonEntityNormalized, recallNorm.NonEntityNormalized)
+ }
+ if !containsTok(teachNorm.Entities, "usa") {
+ t.Errorf("teach side should have promoted 'usa'; got %v", teachNorm.Entities)
+ }
+}
+
+// promoteStubResolver is a test-only EntityResolver backed by an
+// in-memory map. Distinct from stubResolver in playbooks_test.go so
+// neither test file owns the helper -- both work against the shared
+// EntityResolver interface.
+type promoteStubResolver struct {
+ canonicals map[string][]string
+}
+
+func newPromoteStubResolver() *promoteStubResolver {
+ return &promoteStubResolver{canonicals: map[string][]string{}}
+}
+
+func (s *promoteStubResolver) set(token string, canonicals ...string) {
+ s.canonicals[token] = canonicals
+}
+
+func (s *promoteStubResolver) Resolve(token string) []string {
+ if s == nil {
+ return nil
+ }
+ out := s.canonicals[token]
+ if len(out) == 0 {
+ return nil
+ }
+ return append([]string(nil), out...)
+}
+
+func (s *promoteStubResolver) ResolveSet(tokens []string) map[string]struct{} {
+ if s == nil {
+ return nil
+ }
+ out := map[string]struct{}{}
+ for _, t := range tokens {
+ for _, c := range s.canonicals[t] {
+ out[c] = struct{}{}
+ }
+ }
+ return out
+}
+
+func containsTok(haystack []string, needle string) bool {
+ for _, h := range haystack {
+ if h == needle {
+ return true
+ }
+ }
+ return false
+}
+
+func sortedCopy(s []string) []string {
+ out := append([]string(nil), s...)
+ sort.Strings(out)
+ return out
+}
diff --git a/library/productivity/human-goat/internal/learn/protocol.go b/library/productivity/human-goat/internal/learn/protocol.go
new file mode 100644
index 0000000000..1d80458899
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/protocol.go
@@ -0,0 +1,16 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+// RecallFirstProtocol is the recall-first agent protocol for this CLI's
+// learn loop. Mechanism: a single generated Go constant is the shared
+// source of truth consumed by BOTH agent surfaces, the MCP context tool
+// (handleContext in internal/mcp) and the CLI agent-context command
+// (internal/cli), so the two surfaces reference one constant instead of
+// carrying parallel prose that could drift.
+const RecallFirstProtocol = `1. Recall first: before answering a domain query, run recall --query "" --agent and apply any verified learnings, playbook steps, and notes it returns.
+2. Empty-store short-circuit: if the store has no learnings, playbooks, or candidates yet (recall finds nothing and learnings list and learnings candidates are both empty), skip recall for the rest of this session instead of taxing every query; resume recall-first once something has been taught.
+3. Candidates are try-then-confirm, never facts: entries in the recall envelope's candidates section are unverified. Follow each candidate's two-step next_action verbatim: run the trial command first, then run learnings confirm only after the trial verified the behavior. Reject a wrong candidate with learnings reject .
+4. Teach contract: after resolving a query the store could not answer, teach the structural query shape with identifiers stripped: never include names, emails, phone numbers, account ids, or other personal identifiers in taught queries or notes.
+5. Never re-teach what recall already surfaced as a candidate: confirm or reject that candidate instead of teaching a duplicate.`
diff --git a/library/productivity/human-goat/internal/learn/recall.go b/library/productivity/human-goat/internal/learn/recall.go
new file mode 100644
index 0000000000..f938665475
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/recall.go
@@ -0,0 +1,1124 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/lookups"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/patterns"
+)
+
+// Default thresholds. Keep in sync with the documented contract in
+// SKILL.md: the recall match-score floor is 0.6 (token-set Jaccard)
+// and the default result cap is 10.
+//
+// defaultCrossAliasJaccardMin is the floor applied specifically when
+// the canonical-overlap fallback fires. Cross-alias matches differ on
+// literal entity strings, so non-entity Jaccard is the only signal
+// left to score on, and it's naturally lower for paraphrased
+// same-shape queries -- a separate floor avoids gating out legitimate
+// paraphrase hits.
+const (
+ defaultJaccardMin = 0.6
+ defaultCrossAliasJaccardMin = 0.3
+ defaultRecallLimit = 10
+ defaultMinConfidence = 1
+)
+
+// SourcePattern marks a Hit synthesized by the pattern substitution
+// engine rather than read from search_learnings.
+const SourcePattern = "pattern"
+
+// LearningActionBoost mirrors the action string stored in
+// search_learnings for a default rerank rule.
+const LearningActionBoost = "boost"
+
+// Hit is one row in the recall envelope. Field tags mirror the JSON
+// contract agents read.
+//
+// LearningID is the search_learnings row id backing this hit (0 for
+// pattern-synthesized hits, which have no backing row). The command
+// side records it as the recall_hit event's matched_row_id so
+// teach-to-reuse joins by row id — an alias-mediated or paraphrased
+// recall then credits the taught row even when the query family
+// differs from the teach-time family.
+type Hit struct {
+ LearningID int64 `json:"learning_id,omitempty"`
+ ResourceID string `json:"resource_id"`
+ ResourceType string `json:"resource_type,omitempty"`
+ Venue string `json:"venue,omitempty"`
+ Action string `json:"action"`
+ Confidence int `json:"confidence"`
+ MatchScore float64 `json:"match_score"`
+ EntityMatch string `json:"entity_match"`
+ ResourceEntities []string `json:"resource_entities,omitempty"`
+ Source string `json:"source"`
+ LastObservedAt *time.Time `json:"last_observed_at,omitempty"`
+ AliasTarget string `json:"alias_target,omitempty"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+// Result is the top-level recall envelope. Found mirrors the legacy
+// {found, results} shape with additive entity-aware fields.
+//
+// Playbook is non-nil when the query's structural family matches a
+// stored learning_playbooks row. The agent reads it before any
+// discovery step. Notes mirror playbook.notes_text verbatim so the
+// agent can surface the gotchas/workarounds even when the structured
+// playbook itself is sparse (or absent -- a notes-only row still
+// surfaces Notes).
+//
+// Candidates is the quarantined-knowledge section: open
+// learn_candidates rows surfaced for judgment. Structurally separate
+// by contract -- a candidate never appears inside Results, Playbook,
+// or Notes, and the key is omitted entirely when nothing is open so
+// the envelope stays byte-stable for stores with no pending
+// candidates.
+//
+// Family is the structural query family key (QueryFamily over the
+// promoted normalized form) — internal plumbing for the command
+// side's event instrumentation, deliberately kept out of the JSON
+// envelope (the agent-facing family surface is the playbook block).
+type Result struct {
+ Query string `json:"query"`
+ Family string `json:"-"`
+ Normalized string `json:"normalized"`
+ QueryEntities []string `json:"query_entities"`
+ Found bool `json:"found"`
+ MatchScore float64 `json:"match_score,omitempty"`
+ Results []Hit `json:"results"`
+ Mismatches []Hit `json:"mismatches,omitempty"`
+ Warnings []string `json:"warnings,omitempty"`
+ Playbook *ResolvedPlaybook `json:"playbook,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ Candidates []Candidate `json:"candidates,omitempty"`
+ // UnresolvedEntities feeds the invocation journal's learn context
+ // (SetJournalLearnContext) so derivation and synthesis can anchor
+ // on the recall entry. Never serialized into the envelope.
+ UnresolvedEntities []string `json:"-"`
+}
+
+// Opts tunes Recall behavior. Zero-value defaults:
+//
+// MinConfidence -> 1 (any row)
+// Limit -> 10
+// JaccardMin -> 0.6
+// EntityConfig -> entities.NewConfig() (default English stopwords, no ticker patterns)
+//
+// DebugMismatches surfaces the mismatches array in the envelope.
+// NoLearn short-circuits to an empty envelope.
+//
+// EntityConfig is the per-CLI entity extractor config built once at
+// startup. Carried on Opts rather than positionally so callers that
+// don't need per-CLI configuration (legacy prediction-goat lineage,
+// future opt-out CLIs) can pass Opts{} and inherit defaults.
+//
+// ResourceTypeFields maps each resource_type to the ordered list of
+// JSON fields ResourceEntitiesFromJSON should read to pull entity
+// tokens from a stored resource row. Empty / missing entries leave
+// the resource side with no entity signal (partial / unknown).
+//
+// PatternKinds is the CLI's per-spec list of table-backed lookup
+// kinds (from LearnConfig.EntityLookupSeeds) the pattern engine
+// should try ahead of the built-in computed kinds.
+type Opts struct {
+ MinConfidence int
+ Limit int
+ JaccardMin float64
+ // CrossAliasJaccardMin is the floor used when the canonical-overlap
+ // fallback fires. Defaults to 0.3 (defaultCrossAliasJaccardMin) when
+ // zero. Cross-alias matches share canonicals across different
+ // literal aliases, so non-entity Jaccard is naturally lower and a
+ // separate floor avoids gating out legitimate paraphrase hits.
+ CrossAliasJaccardMin float64
+ DebugMismatches bool
+ NoLearn bool
+ EntityConfig *entities.Config
+ ResourceTypeFields map[string][]string
+ PatternKinds []string
+}
+
+// Recall is the entity-aware read path. db is the open *sql.DB
+// pointing at the local SQLite store with the v6 learn schema; the
+// per-CLI entity extractor config is carried on opts.EntityConfig
+// (nil falls back to entities.NewConfig() defaults).
+//
+// Returns a non-nil Result on every call: cold queries get the
+// envelope shape populated with the normalized form and the query
+// entities. Errors are reserved for DB-level failures.
+func Recall(ctx context.Context, db *sql.DB, query string, opts Opts) (Result, error) {
+ cfg := opts.EntityConfig
+ if cfg == nil {
+ cfg = entities.NewConfig()
+ }
+ normalized := Normalize(query, cfg)
+ result := Result{
+ Query: query,
+ Normalized: normalized.NonEntityNormalized,
+ QueryEntities: append([]string(nil), normalized.Entities...),
+ Results: []Hit{},
+ }
+ if result.QueryEntities == nil {
+ result.QueryEntities = []string{}
+ }
+ if opts.NoLearn {
+ return result, nil
+ }
+
+ minConf := opts.MinConfidence
+ if minConf <= 0 {
+ minConf = defaultMinConfidence
+ }
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = defaultRecallLimit
+ }
+ jMin := opts.JaccardMin
+ if jMin == 0 {
+ jMin = defaultJaccardMin
+ }
+ if jMin < 0 {
+ jMin = 0
+ }
+ crossAliasMin := opts.CrossAliasJaccardMin
+ if crossAliasMin == 0 {
+ crossAliasMin = defaultCrossAliasJaccardMin
+ }
+ if crossAliasMin < 0 {
+ crossAliasMin = 0
+ }
+
+ if len(strings.Fields(normalized.NonEntityNormalized)) == 0 && len(normalized.Entities) == 0 && len(normalized.Tickers) == 0 {
+ return result, nil
+ }
+
+ // Build a per-call canonical resolver. Looks up each entity in
+ // entity_lookups to find the canonical(s) it belongs to. Caches
+ // per-call so a query with N entities does N lookups max, not
+ // N*M where M is the number of candidate rows.
+ resolver := NewCanonicalResolver(ctx, db)
+
+ // Post-Normalize entity promotion via entity_lookups. The
+ // capitalization-based entity extractor misses aliases that the
+ // lookup table knows about (numeric prefixes, lowercase short
+ // codes) because they don't match its detection rules.
+ // PromoteEntities walks the non-entity tokens and promotes any
+ // whose lowercased form has a row in entity_lookups. Same helper
+ // runs at teach time so stored query_entities stays symmetric
+ // with what recall sees.
+ normalized = PromoteEntities(normalized, resolver)
+ result.Family = QueryFamily(normalized)
+ queryTokens := strings.Fields(normalized.NonEntityNormalized)
+ result.QueryEntities = append([]string(nil), normalized.Entities...)
+ if result.QueryEntities == nil {
+ result.QueryEntities = []string{}
+ }
+ result.Normalized = normalized.NonEntityNormalized
+
+ queryCanonicals := resolver.ResolveSet(normalized.Entities)
+ // Ambiguous-alias warning surfaces only when a SINGLE query entity
+ // resolves to multiple canonicals. A perfectly ordinary multi-entity
+ // query resolves to two canonicals via two different entities and
+ // must NOT trip this warning. The agent can pass --debug-mismatches
+ // to investigate.
+ for _, e := range normalized.Entities {
+ if len(resolver.Resolve(e)) > 1 {
+ result.Warnings = append(result.Warnings, WarningAmbiguousAlias)
+ break
+ }
+ }
+
+ // Lookup-refresh capture: entities with no entity_lookups row under
+ // ANY source are recorded as recall lookup misses. The post-sync
+ // scanner (lookups.RefreshFromSynced) uses the recorded set as its
+ // noise filter — only entities a recall actually failed to resolve
+ // are eligible for derivation from synced resources. Recording is
+ // telemetry-class: best-effort, local-only, never fails the recall.
+ var unresolvedEntities []string
+ for _, e := range normalized.Entities {
+ if len(resolver.Resolve(e)) == 0 {
+ unresolvedEntities = append(unresolvedEntities, e)
+ }
+ }
+ if len(unresolvedEntities) > 0 {
+ _ = lookups.RecordMisses(ctx, db, unresolvedEntities)
+ }
+ result.UnresolvedEntities = append([]string(nil), unresolvedEntities...)
+
+ rows, err := db.QueryContext(ctx, `SELECT id, query_pattern, COALESCE(query_entities, ''),
+ COALESCE(venue, ''), COALESCE(resource_type, ''), resource_id, action,
+ COALESCE(alias_target, ''), source, confidence, created_at, last_observed_at
+ FROM search_learnings
+ WHERE confidence >= ?`, minConf)
+ if err != nil {
+ return result, fmt.Errorf("recall query: %w", err)
+ }
+ defer rows.Close()
+
+ var hits []Hit
+ var mismatches []Hit
+ // Track canonicals of stored rows routed to mismatches[] so we
+ // can surface a top-level "similar_shape_different_entity"
+ // warning even when --debug-mismatches isn't passed. The agent
+ // then sees that a structurally-similar learning exists for a
+ // different entity, rather than the misleading
+ // no_learnings_for_query_family.
+ mismatchCanonicals := make(map[string]struct{})
+
+ for rows.Next() {
+ var (
+ learningID int64
+ queryPattern string
+ storedEntities string
+ venue string
+ resourceType string
+ resourceID string
+ action string
+ aliasTarget string
+ source string
+ confidence int
+ createdAt time.Time
+ lastObserved sql.NullTime
+ )
+ if err := rows.Scan(&learningID, &queryPattern, &storedEntities, &venue, &resourceType,
+ &resourceID, &action, &aliasTarget, &source, &confidence, &createdAt, &lastObserved); err != nil {
+ return result, fmt.Errorf("recall scan: %w", err)
+ }
+
+ storedNorm := Normalize(queryPattern, cfg)
+ storedEntitySlice, _ := ParseStoredEntities(storedEntities)
+ if len(storedEntitySlice) == 0 {
+ storedEntitySlice = storedNorm.Entities
+ }
+ // Opportunistic backfill for legacy null-entity rows. Rows
+ // written before symmetric teach-time promotion landed have
+ // query_entities=null and storedNorm.Entities=empty because
+ // query_pattern is lowercased on write. Walk the lowercased
+ // query_pattern tokens through the resolver and use canonical-
+ // resolvable tokens as the effective entity slice for cross-
+ // alias matching this call. Read-only -- the stored column
+ // stays null so we never silently rewrite user data.
+ if len(storedEntitySlice) == 0 {
+ for _, tok := range strings.Fields(strings.ToLower(queryPattern)) {
+ if cfg != nil && cfg.IsStopword(tok) {
+ continue
+ }
+ if cans := resolver.Resolve(tok); len(cans) > 0 {
+ storedEntitySlice = append(storedEntitySlice, tok)
+ }
+ }
+ }
+
+ // Compute the stored non-entity tokens by stripping case-folded
+ // stored entities (from the query_entities column, which preserves
+ // the original case the entity extractor saw at teach time) plus
+ // stopwords from the lowercased query_pattern. The plain
+ // Normalize(queryPattern, cfg) misses entities whose stored form
+ // is uppercase because query_pattern is lowercased on write --
+ // the capitalization-based entity detector can't recover the
+ // entity in the lowercased form. Using the stored entity column
+ // directly rebuilds the correct non-entity token set.
+ storedEntitySet := make(map[string]struct{}, len(storedEntitySlice))
+ for _, e := range storedEntitySlice {
+ storedEntitySet[strings.ToLower(strings.TrimSpace(e))] = struct{}{}
+ }
+ storedNonEntityTokens := make([]string, 0)
+ for _, raw := range strings.Fields(strings.ToLower(queryPattern)) {
+ if _, isEntity := storedEntitySet[raw]; isEntity {
+ continue
+ }
+ // cfg may be the zero-value EntityConfig when a caller passes
+ // Opts{} without an EntityConfig; guard the nil receiver to
+ // match the backfill loop above and avoid a runtime panic on
+ // the first scanned row.
+ if cfg != nil && cfg.IsStopword(raw) {
+ continue
+ }
+ storedNonEntityTokens = append(storedNonEntityTokens, raw)
+ }
+
+ score := Jaccard(queryTokens, storedNonEntityTokens)
+
+ // Resolve stored entities to canonicals for cross-alias matching.
+ // Combined with queryCanonicals computed once at the top, this
+ // lets a query alias match a row taught under a different alias
+ // when both entities resolve to the same canonical via entity_lookups.
+ storedCanonicals := resolver.ResolveSet(storedEntitySlice)
+ canonicalOverlap := setIntersects(queryCanonicals, storedCanonicals)
+
+ // Two fallback paths when literal non-entity Jaccard misses:
+ // 1. Entity-only fallback: both sides have empty non-entity
+ // content, both have entities -> use literal entity-Jaccard.
+ // 2. Cross-alias fallback: canonicals overlap even when literal
+ // entities don't. Also covers paraphrased same-shape queries
+ // where non-entity Jaccard is naturally lower; a separate
+ // CrossAliasJaccardMin floor lets these through. The
+ // downstream validateResource still gates entity match
+ // against the stored resource, so an ambiguous query that
+ // happens to canonical-overlap a stored row still gets
+ // caught at that layer.
+ if score < jMin {
+ if len(queryTokens) == 0 && len(storedNonEntityTokens) == 0 &&
+ len(normalized.Entities) > 0 && len(storedEntitySlice) > 0 {
+ score = Jaccard(normalized.Entities, storedEntitySlice)
+ }
+ if score < jMin {
+ // Three fallback branches, gated by the lower
+ // crossAliasMin floor:
+ // - canonicalOverlap: cross-alias hit candidate,
+ // promotion to Exact happens downstream.
+ // - no overlap, both sides have entities,
+ // structural overlap >= crossAliasMin:
+ // similar-shape mismatch candidate. Surfaces in
+ // mismatches[] so the envelope warning carries
+ // an alternative canonical instead of misleading
+ // no_learnings_for_query_family.
+ // - otherwise: row is genuine noise, drop it.
+ switch {
+ case canonicalOverlap:
+ // Case 1's purpose is cross-alias matching: the
+ // query and stored row use DIFFERENT literal
+ // entities that resolve to the same canonical.
+ // The canonicalJaccard boost to 1.0 (single
+ // canonical on each side, identical) rewards that
+ // real cross-alias hit. But when query and stored
+ // share the SAME literal entity that happens to
+ // be in entity_lookups, canonicalOverlap is also
+ // true and canonicalJaccard is also 1.0 --
+ // boosting structurally-unrelated rows to score=1.0
+ // and admitting them above jMin. Guard the boost:
+ // only fire when literal entities genuinely differ.
+ // Same-entity rows that miss jMin are genuine
+ // structural noise and should drop.
+ if entitySlicesIntersect(normalized.Entities, storedEntitySlice) {
+ continue
+ }
+ canonScore := canonicalJaccard(queryCanonicals, storedCanonicals)
+ if canonScore > score {
+ score = canonScore
+ }
+ if score < crossAliasMin {
+ continue
+ }
+ case len(normalized.Entities) > 0 && len(storedEntitySlice) > 0:
+ // Case 2 is the "similar shape, different entity"
+ // candidate path -- its job is to surface
+ // similar_shape_different_entity warnings, not
+ // admit hits the jMin floor would otherwise reject.
+ // If query and stored share a literal entity yet
+ // canonicalOverlap is false, entity_lookups has no
+ // row for that entity (queryCanonicals and
+ // storedCanonicals are both empty, so setIntersects
+ // returned false). Without this guard the looser
+ // crossAliasMin floor would silently downgrade
+ // jMin to 0.3 for any unregistered entity. Drop
+ // such rows instead -- case 1 covers the matching-
+ // canonical path, case 2 is reserved for actually
+ // different entities.
+ if entitySlicesIntersect(normalized.Entities, storedEntitySlice) {
+ continue
+ }
+ if score < crossAliasMin {
+ continue
+ }
+ default:
+ continue
+ }
+ }
+ }
+
+ hit := Hit{
+ LearningID: learningID,
+ ResourceID: resourceID,
+ ResourceType: resourceType,
+ Venue: venue,
+ Action: action,
+ Confidence: confidence,
+ MatchScore: score,
+ Source: source,
+ AliasTarget: aliasTarget,
+ }
+ if lastObserved.Valid {
+ t := lastObserved.Time
+ hit.LastObservedAt = &t
+ }
+ validateResource(ctx, db, cfg, &hit, normalized.Entities, storedEntitySlice, opts.ResourceTypeFields)
+ // Cross-alias promotion: if canonicals overlap, the entities
+ // are equivalent even when their literal forms differ. Override
+ // a Mismatch verdict so the learning isn't filtered into the
+ // mismatches bucket. The warning flags it for diagnostic clarity.
+ if canonicalOverlap && hit.EntityMatch == EntityMatchMismatch {
+ hit.EntityMatch = EntityMatchExact
+ hit.Warnings = append(hit.Warnings, WarningCrossAliasMatch)
+ }
+ if hit.EntityMatch == EntityMatchMismatch {
+ // Surface canonicals for the envelope-level similar-shape
+ // warning. Fall back to literal stored entities when the
+ // row has no canonical resolution -- better to name the
+ // raw entity than to silently drop the hint.
+ if len(storedCanonicals) > 0 {
+ for c := range storedCanonicals {
+ mismatchCanonicals[c] = struct{}{}
+ }
+ } else {
+ for _, e := range storedEntitySlice {
+ if e = strings.TrimSpace(e); e != "" {
+ mismatchCanonicals[e] = struct{}{}
+ }
+ }
+ }
+ mismatches = append(mismatches, hit)
+ } else {
+ hits = append(hits, hit)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return result, fmt.Errorf("recall rows: %w", err)
+ }
+
+ // Generalization layer: ask the pattern engine whether any template
+ // applies to this query. Errors are swallowed; pattern hits are
+ // additive on top of direct hits.
+ patternHits, _ := patterns.Apply(ctx, db, query, normalized.NonEntityNormalized, normalized.Entities, patterns.Opts{
+ JaccardMin: jMin,
+ Limit: limit,
+ AdditionalKinds: opts.PatternKinds,
+ })
+ if len(patternHits) > 0 {
+ existing := make(map[string]struct{}, len(hits))
+ for _, h := range hits {
+ existing[hitKey(h.ResourceType, h.ResourceID)] = struct{}{}
+ }
+ for _, ph := range patternHits {
+ key := hitKey(ph.ResourceType, ph.ResourceID)
+ if _, dup := existing[key]; dup {
+ continue
+ }
+ existing[key] = struct{}{}
+ hits = append(hits, Hit{
+ ResourceID: ph.ResourceID,
+ ResourceType: ph.ResourceType,
+ Venue: ph.Venue,
+ Action: LearningActionBoost,
+ Confidence: ph.Confidence,
+ MatchScore: ph.MatchScore,
+ EntityMatch: EntityMatchExact,
+ ResourceEntities: ph.ResourceEntities,
+ Source: SourcePattern,
+ LastObservedAt: ph.LastObservedAt,
+ })
+ }
+ }
+
+ sortHits(hits)
+ sortHits(mismatches)
+
+ if len(hits) > limit {
+ hits = hits[:limit]
+ }
+ if len(mismatches) > limit {
+ mismatches = mismatches[:limit]
+ }
+
+ if hits == nil {
+ result.Results = []Hit{}
+ } else {
+ result.Results = hits
+ }
+ if opts.DebugMismatches {
+ if mismatches == nil {
+ result.Mismatches = []Hit{}
+ } else {
+ result.Mismatches = mismatches
+ }
+ }
+ result.Found = len(hits) > 0
+ if result.Found {
+ result.MatchScore = hits[0].MatchScore
+ }
+
+ // Surface mismatches whose structural shape matches the query
+ // as envelope-level warnings naming the alternative canonical.
+ // Same-shape rows already passed the Jaccard floor; the only
+ // reason they landed in mismatches is the entity differed.
+ // Emitting the canonical here lets the agent see that a
+ // similar-shape learning exists for a different entity, instead
+ // of treating it as a cold start.
+ if len(mismatchCanonicals) > 0 {
+ canonicals := make([]string, 0, len(mismatchCanonicals))
+ for c := range mismatchCanonicals {
+ canonicals = append(canonicals, c)
+ }
+ sort.Strings(canonicals)
+ for _, c := range canonicals {
+ result.Warnings = append(result.Warnings, WarningSimilarShapeDifferentEntity+":"+c)
+ }
+ }
+
+ if !result.Found && len(mismatches) == 0 {
+ result.Warnings = append(result.Warnings, TopWarningNoLearningsForQueryFamily)
+ }
+
+ // Stateless run-sync suggestion: a cold recall whose entities have
+ // no lookup coverage under any source points the agent at `sync` —
+ // the post-sync scanner derives synced lookup rows from local data,
+ // healing print-time seed staleness without a reprint. No candidate
+ // row backs this; the warning is recomputed each call and clears on
+ // its own once the entity resolves.
+ if !result.Found {
+ for _, e := range unresolvedEntities {
+ result.Warnings = append(result.Warnings,
+ fmt.Sprintf("%s:%s (run sync to refresh entity lookups)", TopWarningLookupRefreshAvailable, e))
+ }
+ }
+
+ // Playbook + notes surface: orthogonal to the per-resource path.
+ // Look up the structural query family in learning_playbooks. A hit
+ // attaches the resolved playbook (with slot bindings) and the notes
+ // text verbatim so the agent reads the gotchas before any step.
+ //
+ // sql.ErrNoRows is the common case; any other error is swallowed to
+ // preserve the legacy contract that recall doesn't fail when the
+ // optional learning_playbooks table is absent or queryable in
+ // unexpected ways. Parse errors on a malformed playbook_json log by
+ // omission -- the row's Notes still surface so guidance lands even
+ // when the structured choreography is unusable.
+ family := QueryFamily(normalized)
+ if family != "" {
+ var (
+ playbookJSON sql.NullString
+ notesText sql.NullString
+ pbConfidence int
+ )
+ lookupErr := db.QueryRowContext(ctx,
+ `SELECT COALESCE(playbook_json, ''), COALESCE(notes_text, ''), confidence
+ FROM learning_playbooks WHERE query_family = ?`,
+ family,
+ ).Scan(&playbookJSON, ¬esText, &pbConfidence)
+ if lookupErr == nil {
+ rp := &ResolvedPlaybook{
+ QueryFamily: family,
+ Confidence: pbConfidence,
+ Notes: notesText.String,
+ }
+ if playbookJSON.String != "" {
+ if pb, perr := ParsePlaybook([]byte(playbookJSON.String), "learning_playbooks:"+family); perr == nil {
+ rp.Playbook = pb
+ rp.SlotsResolved = ResolveSlots(pb, normalized, resolver)
+ }
+ // Parse errors are logged-by-omission: keep Notes,
+ // drop the structured playbook. The agent still gets
+ // the human guidance even when the JSON is malformed.
+ }
+ // Only attach when there's at least one piece of content.
+ // An empty row would have been rejected at upsert time, but
+ // defense in depth keeps the envelope tidy if one slips
+ // through.
+ if rp.Notes != "" || len(rp.Playbook.Steps) > 0 {
+ result.Playbook = rp
+ result.Notes = notesText.String
+ }
+ }
+ }
+
+ // Quarantined-candidate surface: open learn_candidates rows ride
+ // the envelope in their own capped section, ranked family-anchor
+ // first. Read-only by contract -- it runs regardless of the
+ // capture-disable switch (which gates journaling and derivation
+ // only) and swallows errors so stores predating the candidates
+ // table keep working. Runs after the playbook lookup because the
+ // resolved playbook's steps feed the command-path-overlap rank
+ // tier.
+ if cands := surfaceCandidates(ctx, db, family, result.Playbook); len(cands) > 0 {
+ result.Candidates = cands
+ result.Warnings = append(result.Warnings, TopWarningCandidatesPresent)
+ }
+
+ return result, nil
+}
+
+func validateResource(ctx context.Context, db *sql.DB, cfg *entities.Config, hit *Hit, queryEntities, storedEntitySlice []string, fieldsByType map[string][]string) {
+ var data string
+ err := db.QueryRowContext(ctx,
+ `SELECT data FROM resources WHERE resource_type = ? AND id = ?`,
+ hit.ResourceType, hit.ResourceID,
+ ).Scan(&data)
+ if err != nil {
+ hit.Warnings = append(hit.Warnings, WarningResourceNotInStore)
+ hit.EntityMatch = ClassifyEntityMatch(queryEntities, storedEntitySlice)
+ if hit.EntityMatch == EntityMatchPartial && len(queryEntities) == 0 && len(storedEntitySlice) == 0 {
+ hit.EntityMatch = EntityMatchUnknown
+ }
+ addLowConfidenceWarning(hit)
+ return
+ }
+ var fields []string
+ if fieldsByType != nil {
+ fields = fieldsByType[hit.ResourceType]
+ }
+ resourceEntities := ResourceEntitiesFromJSON([]byte(data), fields, cfg)
+ hit.ResourceEntities = resourceEntities
+ hit.EntityMatch = ClassifyEntityMatch(queryEntities, resourceEntities)
+ addLowConfidenceWarning(hit)
+}
+
+func addLowConfidenceWarning(hit *Hit) {
+ if hit.Confidence < 2 {
+ hit.Warnings = append(hit.Warnings, WarningLowConfidence)
+ }
+}
+
+func sortHits(hits []Hit) {
+ sort.SliceStable(hits, func(i, j int) bool {
+ pi := entityMatchPriority(hits[i].EntityMatch)
+ pj := entityMatchPriority(hits[j].EntityMatch)
+ if pi != pj {
+ return pi < pj
+ }
+ si := sourcePriority(hits[i].Source)
+ sj := sourcePriority(hits[j].Source)
+ if si != sj {
+ return si < sj
+ }
+ if hits[i].Confidence != hits[j].Confidence {
+ return hits[i].Confidence > hits[j].Confidence
+ }
+ if hits[i].MatchScore != hits[j].MatchScore {
+ return hits[i].MatchScore > hits[j].MatchScore
+ }
+ ai := time.Time{}
+ aj := time.Time{}
+ if hits[i].LastObservedAt != nil {
+ ai = *hits[i].LastObservedAt
+ }
+ if hits[j].LastObservedAt != nil {
+ aj = *hits[j].LastObservedAt
+ }
+ return ai.After(aj)
+ })
+}
+
+func sourcePriority(source string) int {
+ if source == SourcePattern {
+ return 1
+ }
+ return 0
+}
+
+func hitKey(resourceType, resourceID string) string {
+ return resourceType + "|" + resourceID
+}
+
+func entityMatchPriority(em string) int {
+ switch em {
+ case EntityMatchExact:
+ return 0
+ case EntityMatchPartial:
+ return 1
+ case EntityMatchUnknown:
+ return 2
+ case EntityMatchMismatch:
+ return 3
+ default:
+ return 4
+ }
+}
+
+// entitySlicesIntersect reports whether two literal entity slices
+// share at least one element after case-insensitive comparison.
+// Used to detect "same literal entity" -- normalized.Entities is
+// lowercased by Normalize, but storedEntitySlice comes straight from
+// ParseStoredEntities (which preserves the case the extractor saw at
+// teach time). A naive case-sensitive comparison would miss the match.
+// Same-entity rows must not slip through the lower crossAliasMin floor
+// or get inflated by canonicalJaccard.
+func entitySlicesIntersect(a, b []string) bool {
+ if len(a) == 0 || len(b) == 0 {
+ return false
+ }
+ if len(a) > len(b) {
+ a, b = b, a
+ }
+ seen := make(map[string]struct{}, len(a))
+ for _, v := range a {
+ seen[strings.ToLower(strings.TrimSpace(v))] = struct{}{}
+ }
+ for _, v := range b {
+ if _, ok := seen[strings.ToLower(strings.TrimSpace(v))]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+// setIntersects reports whether two canonical sets share at least one
+// element. Used as the cross-alias gate for entity-classification
+// promotion (Mismatch -> Exact when canonicals overlap).
+func setIntersects(a, b map[string]struct{}) bool {
+ if len(a) == 0 || len(b) == 0 {
+ return false
+ }
+ // Walk the smaller set for efficiency.
+ if len(a) > len(b) {
+ a, b = b, a
+ }
+ for k := range a {
+ if _, ok := b[k]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+// canonicalJaccard returns the Jaccard score between two canonical
+// sets. Used as the score for cross-alias matches when literal
+// Jaccard misses the threshold but canonicals overlap.
+func canonicalJaccard(a, b map[string]struct{}) float64 {
+ if len(a) == 0 || len(b) == 0 {
+ return 0
+ }
+ var inter int
+ for k := range a {
+ if _, ok := b[k]; ok {
+ inter++
+ }
+ }
+ union := len(a) + len(b) - inter
+ if union == 0 {
+ return 0
+ }
+ return float64(inter) / float64(union)
+}
+
+// Candidate classes mirror the CHECK constraint on learn_candidates
+// (and the store package's exported constants; the learn package reads
+// the table directly, like the playbook lookup above, so the literals
+// live here too).
+const (
+ candidateClassFlagAlias = "flag_alias"
+ candidateClassPlaybook = "playbook_candidate"
+)
+
+// candidateSurfaceCap caps the envelope's candidates array. The full
+// open set stays reachable via the `learnings candidates` control
+// command; the envelope only carries the few worth acting on now.
+const candidateSurfaceCap = 3
+
+// candidateScanLimit bounds the single candidates SELECT on the recall
+// hot path. Family-anchored rows sort first inside the query itself,
+// so the bound can never starve the rows most relevant to this query.
+const candidateScanLimit = 24
+
+// candidateCommandBinary is the installed binary name next_action
+// steps are composed against. Byte-exact by contract: agents copy the
+// steps verbatim, so the confirm step must match the command names
+// registered under the `learnings` group.
+const candidateCommandBinary = "human-goat-pp-cli"
+
+// Candidate is one open learn_candidates row surfaced in the recall
+// envelope for judgment. Quarantined knowledge: it sits below the
+// confidence skip floor and alters nothing until an explicit confirm.
+// NextAction is always exactly two steps -- try the candidate, then
+// the literal confirm command -- both copy-exact.
+type Candidate struct {
+ ID int64 `json:"id"`
+ Class string `json:"class"`
+ Summary string `json:"summary"`
+ Sightings int `json:"sightings"`
+ LastSeen time.Time `json:"last_seen"`
+ Rationale string `json:"rationale"`
+ NextAction []string `json:"next_action"`
+}
+
+// candidateScan is the raw row shape surfaceCandidates ranks before
+// composing the envelope entries.
+type candidateScan struct {
+ id int64
+ class string
+ payload string
+ sightings int
+ queryFamily string
+ commandPath string
+ lastSeen time.Time
+}
+
+// surfaceCandidates returns the capped, ranked candidates section for
+// the envelope. One bounded query on the hot path: open rows only,
+// family-anchored rows first, then sightings, then recency. The
+// command-path-overlap tier (between family anchor and raw sightings)
+// is resolved in Go against the already-resolved playbook. Errors are
+// swallowed by contract -- recall never fails, and stores predating
+// the learn_candidates table simply surface nothing.
+func surfaceCandidates(ctx context.Context, db *sql.DB, family string, resolved *ResolvedPlaybook) []Candidate {
+ rows, err := db.QueryContext(ctx, `SELECT id, class, payload, sightings,
+ COALESCE(query_family, ''), COALESCE(command_path, ''), last_seen_at
+ FROM learn_candidates
+ WHERE status = 'open'
+ ORDER BY CASE WHEN ? != '' AND query_family = ? THEN 0 ELSE 1 END,
+ sightings DESC, last_seen_at DESC, id ASC
+ LIMIT ?`, family, family, candidateScanLimit)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+
+ var scanned []candidateScan
+ for rows.Next() {
+ var c candidateScan
+ var lastSeen string
+ if err := rows.Scan(&c.id, &c.class, &c.payload, &c.sightings,
+ &c.queryFamily, &c.commandPath, &lastSeen); err != nil {
+ return nil
+ }
+ // Tolerant parse: a foreign timestamp yields the zero time
+ // rather than dropping the row.
+ if t, perr := time.Parse(time.RFC3339, lastSeen); perr == nil {
+ c.lastSeen = t
+ }
+ scanned = append(scanned, c)
+ }
+ if rows.Err() != nil || len(scanned) == 0 {
+ return nil
+ }
+
+ tier := func(c candidateScan) int {
+ switch {
+ case family != "" && c.queryFamily == family:
+ return 0
+ case playbookUsesCommandPath(resolved, c.commandPath):
+ return 1
+ default:
+ return 2
+ }
+ }
+ // High-sighting unconfirmed rows must outrank fresh low-sighting
+ // ones within a tier: the no-confirm steady state depends on the
+ // same proven candidates resurfacing session after session.
+ sort.SliceStable(scanned, func(i, j int) bool {
+ ti, tj := tier(scanned[i]), tier(scanned[j])
+ if ti != tj {
+ return ti < tj
+ }
+ if scanned[i].sightings != scanned[j].sightings {
+ return scanned[i].sightings > scanned[j].sightings
+ }
+ if !scanned[i].lastSeen.Equal(scanned[j].lastSeen) {
+ return scanned[i].lastSeen.After(scanned[j].lastSeen)
+ }
+ return scanned[i].id < scanned[j].id
+ })
+ if len(scanned) > candidateSurfaceCap {
+ scanned = scanned[:candidateSurfaceCap]
+ }
+
+ out := make([]Candidate, 0, len(scanned))
+ for _, c := range scanned {
+ out = append(out, Candidate{
+ ID: c.id,
+ Class: c.class,
+ Summary: candidateSummary(c.class, c.payload, c.queryFamily),
+ Sightings: c.sightings,
+ LastSeen: c.lastSeen,
+ Rationale: candidateRationale(tier(c), c.sightings),
+ NextAction: candidateNextAction(c.id, c.class, c.payload, c.commandPath),
+ })
+ }
+ return out
+}
+
+// playbookUsesCommandPath reports whether the resolved playbook runs
+// the candidate's command path in any step -- the middle ranking tier:
+// a correction observed on a command this query's choreography uses is
+// more actionable here than an unrelated one.
+func playbookUsesCommandPath(resolved *ResolvedPlaybook, commandPath string) bool {
+ if resolved == nil {
+ return false
+ }
+ commandPath = strings.TrimSpace(commandPath)
+ if commandPath == "" {
+ return false
+ }
+ for _, step := range resolved.Playbook.Steps {
+ cmd := strings.TrimSpace(step.Cmd)
+ if cmd == commandPath || strings.HasPrefix(cmd, commandPath+" ") {
+ return true
+ }
+ }
+ return false
+}
+
+// candidateSummary renders a compact, human-scannable digest of the
+// payload -- never the full payload; `learnings confirm` prints that
+// before anything is blessed. Malformed payloads fall back to a
+// truncated verbatim form so the row stays identifiable.
+func candidateSummary(class, payload, family string) string {
+ switch class {
+ case candidateClassFlagAlias:
+ var p struct {
+ CommandPath string `json:"command_path"`
+ FailedFlag string `json:"failed_flag"`
+ CorrectedFlag string `json:"corrected_flag"`
+ }
+ if err := json.Unmarshal([]byte(payload), &p); err == nil &&
+ strings.TrimSpace(p.FailedFlag) != "" && strings.TrimSpace(p.CorrectedFlag) != "" {
+ if strings.TrimSpace(p.CommandPath) != "" {
+ return fmt.Sprintf("observed flag correction on `%s`: use %s instead of %s",
+ strings.TrimSpace(p.CommandPath), p.CorrectedFlag, p.FailedFlag)
+ }
+ return fmt.Sprintf("observed flag correction: use %s instead of %s", p.CorrectedFlag, p.FailedFlag)
+ }
+ case candidateClassPlaybook:
+ var p struct {
+ QueryFamily string `json:"query_family"`
+ PlaybookJSON string `json:"playbook_json"`
+ NotesText string `json:"notes_text"`
+ }
+ if err := json.Unmarshal([]byte(payload), &p); err == nil {
+ if family == "" {
+ family = strings.TrimSpace(p.QueryFamily)
+ }
+ steps := 0
+ if strings.TrimSpace(p.PlaybookJSON) != "" {
+ if pb, perr := ParsePlaybook([]byte(p.PlaybookJSON), "candidate"); perr == nil {
+ steps = len(pb.Steps)
+ }
+ }
+ switch {
+ case steps > 0 && family != "":
+ return fmt.Sprintf("proposed playbook (%d step(s)) for query family %q", steps, family)
+ case steps > 0:
+ return fmt.Sprintf("proposed playbook (%d step(s))", steps)
+ case strings.TrimSpace(p.NotesText) != "" && family != "":
+ return fmt.Sprintf("proposed guidance notes for query family %q", family)
+ case strings.TrimSpace(p.NotesText) != "":
+ return "proposed guidance notes"
+ }
+ }
+ }
+ return truncateForSummary(payload)
+}
+
+// truncateForSummary keeps a malformed payload identifiable in the
+// summary slot without dumping it wholesale. Rune-safe cut.
+func truncateForSummary(payload string) string {
+ const maxRunes = 120
+ payload = strings.TrimSpace(payload)
+ runes := []rune(payload)
+ if len(runes) <= maxRunes {
+ return payload
+ }
+ return string(runes[:maxRunes]) + "..."
+}
+
+// candidateRationale explains in plain words why the row surfaced and
+// how often it has been seen. No scores, no bare floats -- agents read
+// this verbatim.
+func candidateRationale(tier, sightings int) string {
+ times := fmt.Sprintf("seen %d time", sightings)
+ if sightings != 1 {
+ times += "s"
+ }
+ switch tier {
+ case 0:
+ return "matches this query's family; " + times
+ case 1:
+ return "observed on a command this query's playbook runs; " + times
+ default:
+ return "unconfirmed observation from this CLI's own usage; " + times
+ }
+}
+
+// candidateNextAction composes the two byte-exact steps: first the
+// trial derived from the payload (for flag corrections, the corrected
+// example invocation; for playbook candidates, a note to try the
+// surfaced steps), then the literal confirm command registered under
+// the `learnings` group.
+func candidateNextAction(id int64, class, payload, commandPath string) []string {
+ confirm := fmt.Sprintf("%s learnings confirm %d", candidateCommandBinary, id)
+
+ trial := ""
+ if class == candidateClassFlagAlias {
+ var p struct {
+ CommandPath string `json:"command_path"`
+ CorrectedFlag string `json:"corrected_flag"`
+ }
+ if err := json.Unmarshal([]byte(payload), &p); err == nil && strings.TrimSpace(p.CorrectedFlag) != "" {
+ path := strings.TrimSpace(p.CommandPath)
+ if path == "" {
+ path = strings.TrimSpace(commandPath)
+ }
+ if path == "" {
+ path = ""
+ }
+ trial = fmt.Sprintf("%s %s %s ", candidateCommandBinary, path, strings.TrimSpace(p.CorrectedFlag))
+ }
+ }
+ if trial == "" {
+ if class == candidateClassPlaybook {
+ trial = "try the surfaced playbook steps for this query family and verify they answer the query"
+ } else {
+ trial = fmt.Sprintf("review the payload via `%s learnings candidates`, then retry the corrected invocation", candidateCommandBinary)
+ }
+ }
+ return []string{trial, confirm}
+}
+
+// FamilyHash derives the stable, non-reversible key the measurement
+// layer stores as learn_events.query_family_hash. Hashing keeps the
+// events table free of query-derived text (the family key is built
+// from the user's words) while still supporting equality joins for
+// teach-to-reuse fallback, playbook-amend correlation, and the forget
+// cascade. Empty family hashes to "" so callers store NULL.
+func FamilyHash(family string) string {
+ if strings.TrimSpace(family) == "" {
+ return ""
+ }
+ sum := sha256.Sum256([]byte(family))
+ return hex.EncodeToString(sum[:8])
+}
+
+// PII rule names ScanPII returns. Stable vocabulary: the teach-path
+// warning names the rule verbatim, and generated SKILL/AGENTS prose
+// references the same names.
+const (
+ PIIRuleEmail = "email"
+ PIIRulePhone = "phone"
+)
+
+// piiEmailRE matches obvious email-address shapes.
+var piiEmailRE = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
+
+// piiPhoneRE matches obvious phone-number shapes. Deliberately
+// conservative: it requires separator formatting (or an international
+// +prefix) so bare numeric identifiers — resource ids, timestamps,
+// zip+4 codes — never trip the warning.
+var piiPhoneRE = regexp.MustCompile(`(?:\+\d{1,3}[\s.\-]?)?(?:\(\d{3}\)[\s.\-]?|\b\d{3}[\s.\-])\d{3}[\s.\-]\d{4}\b`)
+
+// ScanPII checks freeform text about to be persisted by a teach-side
+// write for obvious personally-identifying shapes and returns the
+// names of the rules that matched (empty when clean). The contract is
+// warn-only: callers surface a stderr warning naming the rule and
+// proceed — never block, never redact silently. Shared by the teach
+// and teach-playbook insert paths.
+func ScanPII(text string) []string {
+ var rules []string
+ if piiEmailRE.MatchString(text) {
+ rules = append(rules, PIIRuleEmail)
+ }
+ if piiPhoneRE.MatchString(text) {
+ rules = append(rules, PIIRulePhone)
+ }
+ return rules
+}
diff --git a/library/productivity/human-goat/internal/learn/recall_canonical_test.go b/library/productivity/human-goat/internal/learn/recall_canonical_test.go
new file mode 100644
index 0000000000..f94d4ec30e
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/recall_canonical_test.go
@@ -0,0 +1,836 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Cross-alias canonical-resolution tests.
+//
+// Teaching "Alpha widget" -> resource A1 is supposed to make "A1 widget"
+// / "AL widget" hit the same learning from a cold start, because the
+// seeded entity_lookups table records all three as values under the
+// canonical "Alpha-Widget-Canonical". The literal-alias path covers
+// same-alias queries; this file locks in the canonical-resolution path
+// plus the Greptile-flagged guards that keep same-entity / no-lookup
+// rows from being silently admitted via the looser cross-alias floor.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+
+ _ "modernc.org/sqlite"
+)
+
+func openRecallCanonicalTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "canonical.db")
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ for _, q := range []string{
+ `CREATE TABLE resources (
+ resource_type TEXT NOT NULL,
+ id TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`,
+ `CREATE TABLE search_learnings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_pattern TEXT NOT NULL,
+ query_entities TEXT,
+ venue TEXT,
+ resource_type TEXT,
+ resource_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ alias_target TEXT,
+ source TEXT NOT NULL,
+ confidence INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ notes TEXT
+ )`,
+ `CREATE UNIQUE INDEX idx_learn_unique ON search_learnings(query_pattern, resource_id, action)`,
+ `CREATE TABLE search_patterns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_template TEXT NOT NULL,
+ resource_template TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ venue TEXT,
+ strategy TEXT NOT NULL,
+ entity_kind TEXT NOT NULL,
+ confidence INTEGER NOT NULL DEFAULT 2,
+ source TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ example_query TEXT,
+ example_resource TEXT
+ )`,
+ `CREATE TABLE entity_lookups (
+ kind TEXT NOT NULL,
+ canonical TEXT NOT NULL,
+ value TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'seeded',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (kind, canonical, value)
+ )`,
+ `CREATE TABLE learning_playbooks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_family TEXT NOT NULL UNIQUE,
+ playbook_json TEXT,
+ notes_text TEXT,
+ source TEXT NOT NULL,
+ confidence INTEGER NOT NULL DEFAULT 2,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME
+ )`,
+ } {
+ if _, err := db.Exec(q); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ }
+ return db
+}
+
+// canonicalTestConfig builds an entities.Config with a few generic
+// stopwords typical of every printed CLI ("widget", "thing", "today",
+// "tonight") so the entity extractor strips them and leaves only the
+// entity aliases.
+func canonicalTestConfig() *entities.Config {
+ cfg := entities.NewConfig()
+ cfg.RegisterStopwords("widget", "widgets", "thing", "things", "today", "tonight", "this", "the", "vs", "v", "versus")
+ return cfg
+}
+
+// seedCanonicalLookup inserts a (kind, canonical, value) row into
+// entity_lookups. Mirrors how lookups.SeedFromConfig writes seeds.
+func seedCanonicalLookup(t *testing.T, db *sql.DB, kind, canonical string, values []string) {
+ t.Helper()
+ for _, v := range values {
+ if _, err := db.Exec(
+ `INSERT OR IGNORE INTO entity_lookups (kind, canonical, value, source) VALUES (?, ?, ?, ?)`,
+ kind, canonical, v, "seeded",
+ ); err != nil {
+ t.Fatalf("seed lookup: %v", err)
+ }
+ }
+}
+
+// seedCanonicalLearning writes a learning row directly via SQL to
+// avoid coupling these tests to the (still-evolving) write path.
+func seedCanonicalLearning(t *testing.T, db *sql.DB, pattern, entitiesJSON, resourceID, resourceType string) {
+ t.Helper()
+ if _, err := db.Exec(`INSERT INTO search_learnings (
+ query_pattern, query_entities, resource_id, resource_type, action, source, confidence
+ ) VALUES (?, ?, ?, ?, 'boost', 'taught', 2)`,
+ pattern, entitiesJSON, resourceID, resourceType,
+ ); err != nil {
+ t.Fatalf("seed learning: %v", err)
+ }
+}
+
+// TestRecall_NilEntityConfig_NoPanic guards the storedNonEntityTokens
+// loop's nil-receiver guard: a caller passing Opts{} (zero-value, no
+// EntityConfig) must not panic when the loop reaches cfg.IsStopword on
+// the first scanned row. Regression for the recall stopword-guard fix.
+func TestRecall_NilEntityConfig_NoPanic(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLearning(t, db, "alpha widget today", `["Alpha"]`, "A1-RESOURCE", "widgets")
+
+ // Opts{} leaves EntityConfig nil. Must not panic on the first row.
+ got, err := Recall(context.Background(), db, "alpha widget today", Opts{})
+ if err != nil {
+ t.Fatalf("recall with nil EntityConfig: %v", err)
+ }
+ _ = got // behavior under nil cfg is best-effort; the contract is "no panic"
+}
+
+// TestRecall_CrossAlias_DifferentLiteralSameCanonical confirms that a
+// query taught with one alias hits a stored row taught with a different
+// alias when both resolve to the same canonical via entity_lookups.
+func TestRecall_CrossAlias_DifferentLiteralSameCanonical(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha", "A1", "AL"})
+ // Teach: "Alpha widget today" -> resource A1
+ seedCanonicalLearning(t, db, "alpha widget today", `["Alpha"]`, "A1-RESOURCE", "widgets")
+
+ // Recall with the different alias "A1"
+ got, err := Recall(context.Background(), db, "A1 widget today", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found {
+ t.Fatalf("want Found=true via cross-alias canonical resolution; got %+v", got)
+ }
+ if len(got.Results) != 1 {
+ t.Fatalf("want 1 result, got %d", len(got.Results))
+ }
+ if got.Results[0].ResourceID != "A1-RESOURCE" {
+ t.Errorf("ResourceID = %q, want A1-RESOURCE", got.Results[0].ResourceID)
+ }
+}
+
+// TestRecall_CrossAlias_PromotesEntityMatchExact confirms a cross-alias
+// hit promotes EntityMatch from Mismatch to Exact and surfaces the
+// cross_alias_match warning on the per-result Warnings slice.
+func TestRecall_CrossAlias_PromotesEntityMatchExact(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha", "A1"})
+ seedCanonicalLearning(t, db, "alpha widget today", `["Alpha"]`, "A1-RESOURCE", "widgets")
+
+ got, err := Recall(context.Background(), db, "A1 widget today", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found || len(got.Results) == 0 {
+ t.Fatalf("want a hit, got %+v", got)
+ }
+ if got.Results[0].EntityMatch != EntityMatchExact {
+ t.Errorf("EntityMatch = %q, want %q (cross-alias should promote Mismatch -> Exact)",
+ got.Results[0].EntityMatch, EntityMatchExact)
+ }
+ foundWarning := false
+ for _, w := range got.Results[0].Warnings {
+ if w == WarningCrossAliasMatch {
+ foundWarning = true
+ break
+ }
+ }
+ if !foundWarning {
+ t.Errorf("want %q warning on cross-alias hit; got %v", WarningCrossAliasMatch, got.Results[0].Warnings)
+ }
+}
+
+// TestRecall_CrossAlias_WrongCanonicalDoesNotPromote confirms different
+// canonicals do NOT promote; the envelope surfaces
+// similar_shape_different_entity instead. The query and stored row share
+// the structural shape "how doing season year" so they both pass the
+// crossAliasMin floor and case 2 routes the row to mismatches with the
+// envelope warning carrying the alternative canonical.
+func TestRecall_CrossAlias_WrongCanonicalDoesNotPromote(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha"})
+ seedCanonicalLookup(t, db, "alpha_kind", "Bravo-Widget-Canonical",
+ []string{"Bravo-Widget-Canonical", "Bravo"})
+ seedCanonicalLearning(t, db, "how alpha doing season year",
+ `["Alpha"]`, "A1-RESOURCE", "widgets")
+
+ got, err := Recall(context.Background(), db,
+ "how are the Bravo doing this year",
+ Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Found {
+ t.Errorf("want Found=false (canonicals differ), got %+v", got)
+ }
+ want := WarningSimilarShapeDifferentEntity + ":Alpha-Widget-Canonical"
+ foundWarning := false
+ for _, w := range got.Warnings {
+ if w == want {
+ foundWarning = true
+ }
+ }
+ if !foundWarning {
+ t.Errorf("want warning %q in envelope; got %v", want, got.Warnings)
+ }
+ for _, w := range got.Warnings {
+ if w == TopWarningNoLearningsForQueryFamily {
+ t.Errorf("similar-shape warning should suppress %q; got %v", TopWarningNoLearningsForQueryFamily, got.Warnings)
+ }
+ }
+}
+
+// TestRecall_TrueCrossAliasHit_DoesNotSurfaceSimilarShapeWarning
+// confirms a row promoted to a real Hit via cross-alias canonical
+// resolution doesn't double-surface as a similar-shape mismatch warning.
+func TestRecall_TrueCrossAliasHit_DoesNotSurfaceSimilarShapeWarning(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha", "A1"})
+ seedCanonicalLearning(t, db, "alpha widget today", `["Alpha"]`, "A1-RESOURCE", "widgets")
+
+ got, err := Recall(context.Background(), db, "A1 widget today", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found {
+ t.Fatalf("cross-alias query should hit; got %+v", got)
+ }
+ for _, w := range got.Warnings {
+ if strings.HasPrefix(w, WarningSimilarShapeDifferentEntity+":") {
+ t.Errorf("real hit should not surface similar-shape warning; got %v", got.Warnings)
+ }
+ }
+}
+
+// TestRecall_CrossAliasJaccardMin_OverlapEnablesLowerFloor confirms a
+// query whose canonical truly overlaps an existing teach (different
+// alias, same canonical) clears the lower floor even when literal
+// non-entity Jaccard is below 0.6.
+func TestRecall_CrossAliasJaccardMin_OverlapEnablesLowerFloor(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha", "A1", "AL"})
+ // Teach with a verbose query, recall with a terse one. Non-entity
+ // Jaccard ratio drops because the term overlap is small, but the
+ // canonical match should still let the row through.
+ seedCanonicalLearning(t, db, "today widget alpha primary home", `["Alpha"]`, "A1-RESOURCE", "widgets")
+
+ got, err := Recall(context.Background(), db, "A1 primary", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found {
+ t.Errorf("cross-alias canonical match should clear the lower floor; got %+v", got)
+ }
+}
+
+// TestRecall_LegacyNullEntityRow_OpportunisticBackfill confirms a row
+// written with query_entities=null still hits via cross-alias because
+// the recall path walks the lowercased query_pattern through the
+// canonical resolver to derive effective entities for the call. The
+// backfill is read-only: the stored column must stay null.
+func TestRecall_LegacyNullEntityRow_OpportunisticBackfill(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha", "A1", "AL"})
+ // Seed a legacy row: query_entities=null, query_pattern contains
+ // lowercase 'alpha' that resolves via entity_lookups.
+ if _, err := db.Exec(`INSERT INTO search_learnings (
+ query_pattern, query_entities, resource_id, resource_type, action, source, confidence
+ ) VALUES (?, NULL, ?, ?, 'boost', 'taught', 2)`,
+ "alpha widget today", "A1-RESOURCE", "widgets",
+ ); err != nil {
+ t.Fatalf("seed legacy row: %v", err)
+ }
+
+ // Recall with a different alias -- cross-alias must still fire
+ // against the legacy null-entity row.
+ got, err := Recall(context.Background(), db, "A1 widget today", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found {
+ t.Errorf("legacy null-entity row should still hit via opportunistic backfill; got %+v", got)
+ }
+
+ // Confirm we did NOT write back: the column should still be NULL.
+ var stored sql.NullString
+ if err := db.QueryRow(
+ `SELECT query_entities FROM search_learnings WHERE resource_id = ?`,
+ "A1-RESOURCE",
+ ).Scan(&stored); err != nil {
+ t.Fatalf("stored row lookup: %v", err)
+ }
+ if stored.Valid && stored.String != "" && stored.String != "null" {
+ t.Errorf("backfill should be read-only; stored column got modified to %q", stored.String)
+ }
+}
+
+// TestRecall_EmptyEntityLookups_FallsBackToLiteral confirms a same-alias
+// query still hits via the literal-entity path even when entity_lookups
+// is empty.
+func TestRecall_EmptyEntityLookups_FallsBackToLiteral(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ // No entity_lookups seeded.
+ seedCanonicalLearning(t, db, "alpha widget today", `["Alpha"]`, "A1-RESOURCE", "widgets")
+
+ got, err := Recall(context.Background(), db, "Alpha widget today", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found {
+ t.Errorf("want Found=true via literal match even without entity_lookups; got %+v", got)
+ }
+}
+
+// TestRecall_AmbiguousAlias_SingleEntityMultipleCanonicals exercises the
+// narrow trigger for ambiguous_alias: a single query entity resolving to
+// multiple canonicals fires the warning.
+func TestRecall_AmbiguousAlias_SingleEntityMultipleCanonicals(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ // "Z1" is an alias under two different canonicals -- single-entity
+ // query resolves to multiple canonicals, must trip ambiguous_alias.
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Z-Canonical",
+ []string{"Alpha-Z-Canonical", "Z1"})
+ seedCanonicalLookup(t, db, "bravo_kind", "Bravo-Z-Canonical",
+ []string{"Bravo-Z-Canonical", "Z1"})
+
+ got, err := Recall(context.Background(), db, "Z1 widget today", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ foundWarning := false
+ for _, w := range got.Warnings {
+ if w == WarningAmbiguousAlias {
+ foundWarning = true
+ }
+ }
+ if !foundWarning {
+ t.Errorf("want %q warning when a single entity resolves to multiple canonicals; got %v",
+ WarningAmbiguousAlias, got.Warnings)
+ }
+}
+
+// TestRecall_AmbiguousAlias_MultiEntityDistinctCanonicalsDoesNotFire
+// confirms the narrow trigger: a multi-entity query where each entity
+// individually resolves to one canonical (even if the union spans
+// multiple) does NOT fire ambiguous_alias.
+func TestRecall_AmbiguousAlias_MultiEntityDistinctCanonicalsDoesNotFire(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha"})
+ seedCanonicalLookup(t, db, "alpha_kind", "Bravo-Widget-Canonical",
+ []string{"Bravo-Widget-Canonical", "Bravo"})
+
+ got, err := Recall(context.Background(), db, "Alpha vs Bravo today", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ for _, w := range got.Warnings {
+ if w == WarningAmbiguousAlias {
+ t.Errorf("multi-entity query with each entity resolving to one canonical must NOT fire %q; got %v",
+ WarningAmbiguousAlias, got.Warnings)
+ }
+ }
+}
+
+// TestRecall_SameEntity_NoCanonicalLookup_DropsBelowJMin guards the
+// Greptile finding on PR #851 round 3: when both query and stored row
+// share a literal entity but entity_lookups has no canonical row for it,
+// queryCanonicals and storedCanonicals are both empty. Without an
+// explicit guard, case 2 of the cross-alias fallback would admit such
+// rows below jMin via the looser crossAliasMin floor -- silently
+// downgrading the effective Jaccard minimum for every unregistered
+// entity. With the guard in place, the row must be dropped at the jMin
+// gate.
+func TestRecall_SameEntity_NoCanonicalLookup_DropsBelowJMin(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ // Intentionally do NOT seed entity_lookups for "Alpha". The stored
+ // row carries "Alpha" as a literal entity; the recall query carries
+ // the same literal entity. Non-entity Jaccard between the two should
+ // fall below jMin so the only fallback that could admit the row is
+ // case 2.
+ seedCanonicalLearning(t, db,
+ "how alpha doing season year",
+ `["Alpha"]`, "12", "widgets")
+
+ got, err := Recall(context.Background(), db,
+ "are the Alpha doing well year overall",
+ Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Found {
+ t.Errorf("same literal entity with no entity_lookups should NOT admit a sub-jMin hit; got %+v", got)
+ }
+ if len(got.Results) > 0 {
+ t.Errorf("expected zero Results, got %d (effective jMin downgraded)", len(got.Results))
+ }
+}
+
+// TestRecall_SameEntity_CanonicalJaccardDoesNotInflateScore guards the
+// second Greptile finding on PR #851: case 1 of the fallback switch
+// (canonicalOverlap branch) used to boost the score via canonicalJaccard
+// for any row whose canonicals overlapped the query -- including
+// same-literal-entity rows where the boost is trivially 1.0. A
+// structurally unrelated stored row could surface at score=1.0 above
+// jMin. The case-1 entitySlicesIntersect guard mirrors the case-2 guard.
+func TestRecall_SameEntity_CanonicalJaccardDoesNotInflateScore(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha"})
+ // Stored row shares the entity ("alpha") and is structurally disjoint
+ // from the recall query: zero non-entity-token overlap. Pre-fix,
+ // canonicalJaccard would boost this to score=1.0.
+ seedCanonicalLearning(t, db,
+ "today scoreboard alpha",
+ `["Alpha"]`, "tv-scoreboard", "tv")
+
+ got, err := Recall(context.Background(), db,
+ "how did alpha end the year stats",
+ Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Found {
+ t.Errorf("structurally disjoint same-entity row must not be admitted via canonical-overlap boost; got %+v", got)
+ }
+ for _, r := range got.Results {
+ if r.ResourceID == "tv-scoreboard" {
+ t.Errorf("unrelated stored row leaked into Results with score=%v", r.MatchScore)
+ }
+ }
+}
+
+// TestRecall_CaseInsensitiveEntitySlicesIntersect_HelperUnit guards the
+// third Greptile finding: stored entities preserve extractor casing
+// (e.g. "Alpha") but other call sites (PromoteEntities walks lowercased
+// query_pattern tokens; the opportunistic backfill in the row loop
+// uses lowercased forms) can land lowercased entities on the query
+// side. A naive case-sensitive entitySlicesIntersect would miss the
+// match and let same-entity rows slip through the lower crossAliasMin
+// floor. Test the helper directly so the contract is locked in
+// regardless of how the call sites evolve.
+func TestRecall_CaseInsensitiveEntitySlicesIntersect_HelperUnit(t *testing.T) {
+ t.Parallel()
+ if !entitySlicesIntersect([]string{"alpha"}, []string{"Alpha"}) {
+ t.Errorf("entitySlicesIntersect must be case-insensitive: lowercase 'alpha' should match 'Alpha'")
+ }
+ if !entitySlicesIntersect([]string{"ALPHA"}, []string{"alpha"}) {
+ t.Errorf("entitySlicesIntersect must be case-insensitive: uppercase 'ALPHA' should match 'alpha'")
+ }
+ if !entitySlicesIntersect([]string{" Alpha "}, []string{"alpha"}) {
+ t.Errorf("entitySlicesIntersect must TrimSpace before comparing")
+ }
+ if entitySlicesIntersect([]string{"alpha"}, []string{"bravo"}) {
+ t.Errorf("entitySlicesIntersect must NOT match distinct entities")
+ }
+ if entitySlicesIntersect(nil, []string{"alpha"}) {
+ t.Errorf("entitySlicesIntersect on nil input must return false")
+ }
+ if entitySlicesIntersect([]string{"alpha"}, nil) {
+ t.Errorf("entitySlicesIntersect on nil input must return false")
+ }
+}
+
+// TestRecall_CaseInsensitiveEntitySlicesIntersect_BackfillPath is an
+// end-to-end repro of the case-insensitive scenario via the
+// opportunistic-backfill code path. The backfill walks LOWERCASED
+// query_pattern tokens and writes them into storedEntitySlice; the
+// query side carries the case-preserved "Alpha" entity. The case-2
+// guard's entitySlicesIntersect must detect them as the same literal
+// entity even though the cases differ, dropping the sub-jMin row.
+func TestRecall_CaseInsensitiveEntitySlicesIntersect_BackfillPath(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ // Seed entity_lookups for "alpha" so the backfill walker can promote
+ // the lowercased token from the legacy query_pattern. The backfill
+ // path produces lowercase storedEntitySlice = ["alpha"]; the query
+ // side promotes "Alpha" (capitalized) as its entity.
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha", "alpha"})
+ // Legacy null-entity row: query_entities=null forces the backfill
+ // walker to derive entities from the lowercased query_pattern.
+ if _, err := db.Exec(`INSERT INTO search_learnings (
+ query_pattern, query_entities, resource_id, resource_type, action, source, confidence
+ ) VALUES (?, NULL, ?, ?, 'boost', 'taught', 2)`,
+ "how alpha doing season year", "12", "widgets",
+ ); err != nil {
+ t.Fatalf("seed legacy row: %v", err)
+ }
+
+ got, err := Recall(context.Background(), db,
+ "are the Alpha doing well year overall",
+ Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ // Same canonical (both sides resolve to Alpha-Widget-Canonical) AND
+ // case-insensitively the literal entities match: case 1 guard fires
+ // and drops the row at the jMin gate. Without the case-fold, the
+ // case-1 guard misses, canonicalJaccard inflates score to 1.0, and
+ // the structurally-unrelated row leaks into Results.
+ if got.Found {
+ t.Errorf("case-insensitive same-entity detection should drop the sub-jMin row; got %+v", got)
+ }
+}
+
+// TestRecall_SimilarShapeDifferentEntity_MultipleCanonicals confirms the
+// warning fires once per alternative canonical when several stored rows
+// share the shape but resolve to different entities.
+func TestRecall_SimilarShapeDifferentEntity_MultipleCanonicals(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha"})
+ seedCanonicalLookup(t, db, "alpha_kind", "Bravo-Widget-Canonical",
+ []string{"Bravo-Widget-Canonical", "Bravo"})
+ seedCanonicalLookup(t, db, "alpha_kind", "Charlie-Widget-Canonical",
+ []string{"Charlie-Widget-Canonical", "Charlie"})
+ seedCanonicalLearning(t, db, "how alpha doing season year",
+ `["Alpha"]`, "12", "widgets")
+ seedCanonicalLearning(t, db, "how bravo doing season year",
+ `["Bravo"]`, "10", "widgets")
+
+ got, err := Recall(context.Background(), db, "how are charlie doing this year", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ wantA := WarningSimilarShapeDifferentEntity + ":Alpha-Widget-Canonical"
+ wantB := WarningSimilarShapeDifferentEntity + ":Bravo-Widget-Canonical"
+ foundA, foundB := false, false
+ for _, w := range got.Warnings {
+ if w == wantA {
+ foundA = true
+ }
+ if w == wantB {
+ foundB = true
+ }
+ }
+ if !foundA || !foundB {
+ t.Errorf("want both %q and %q in envelope; got %v", wantA, wantB, got.Warnings)
+ }
+}
+
+// TestRecall_NoMismatches_KeepsNoLearningsWarning confirms a true
+// cold-start envelope still carries no_learnings_for_query_family.
+func TestRecall_NoMismatches_KeepsNoLearningsWarning(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ got, err := Recall(context.Background(), db, "completely cold query", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ hasNoLearnings := false
+ for _, w := range got.Warnings {
+ if w == TopWarningNoLearningsForQueryFamily {
+ hasNoLearnings = true
+ }
+ }
+ if !hasNoLearnings {
+ t.Errorf("cold envelope should carry %q; got %v", TopWarningNoLearningsForQueryFamily, got.Warnings)
+ }
+}
+
+// seedPlaybookRow inserts a learning_playbooks row directly via SQL.
+// Keeps the playbook-envelope tests decoupled from the store wrapper --
+// the recall path queries the underlying *sql.DB directly.
+func seedPlaybookRow(t *testing.T, db *sql.DB, family, playbookJSON, notes string) {
+ t.Helper()
+ if _, err := db.Exec(
+ `INSERT INTO learning_playbooks (query_family, playbook_json, notes_text, source, confidence)
+ VALUES (?, ?, ?, 'taught', 2)`,
+ family, playbookJSON, notes,
+ ); err != nil {
+ t.Fatalf("seed playbook: %v", err)
+ }
+}
+
+// TestRecall_PlaybookSurfaces_OnFamilyMatch covers U6's primary
+// contract: a query whose QueryFamily matches a stored
+// learning_playbooks row carries the resolved playbook + notes on the
+// recall envelope. Empty results list is fine -- playbooks live on the
+// envelope orthogonally to per-resource hits.
+func TestRecall_PlaybookSurfaces_OnFamilyMatch(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ cfg := canonicalTestConfig()
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha"})
+
+ query := "report Alpha widget today"
+ family := QueryFamily(PromoteEntities(
+ Normalize(query, cfg),
+ NewCanonicalResolver(context.Background(), db),
+ ))
+ if family == "" {
+ t.Fatalf("expected non-empty family for query %q", query)
+ }
+ playbookJSON := `{
+ "query_family_examples": ["report Alpha widget today"],
+ "entity_slots": ["$ENTITY"],
+ "expected_tool_calls": 2,
+ "steps": [
+ {"cmd": "example-pp-cli items list --query {$ENTITY.canonical}", "purpose": "find item"}
+ ]
+ }`
+ seedPlaybookRow(t, db, family, playbookJSON,
+ "Parent rollups sometimes hide per-entity rows; always drill to children.\n")
+
+ got, err := Recall(context.Background(), db, query, Opts{EntityConfig: cfg})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Playbook == nil {
+ t.Fatalf("want Playbook on envelope; got nil. Result=%+v", got)
+ }
+ if got.Playbook.QueryFamily != family {
+ t.Errorf("Playbook.QueryFamily = %q, want %q", got.Playbook.QueryFamily, family)
+ }
+ if len(got.Playbook.Playbook.Steps) == 0 {
+ t.Errorf("want >=1 step on playbook; got 0")
+ }
+ if got.Notes == "" {
+ t.Errorf("want non-empty Notes on envelope; got empty")
+ }
+ if got.Playbook.SlotsResolved["$ENTITY"] == nil {
+ t.Errorf("want $ENTITY slot resolved; got SlotsResolved=%v", got.Playbook.SlotsResolved)
+ }
+}
+
+// TestRecall_PlaybookSurfaces_DifferentEntitySameFamily is the killer-
+// feature test. A playbook seeded for one entity ("Alpha") is retrieved
+// by a structurally-identical query for a different entity ("Bravo").
+// The family key is entity-stripped, so the lookup hits; slot
+// resolution then binds $ENTITY to Bravo's canonical via the per-call
+// resolver. Demonstrates playbooks are entity-agnostic at the slot-
+// binding layer -- one author lift, infinite-entity replay.
+func TestRecall_PlaybookSurfaces_DifferentEntitySameFamily(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ cfg := canonicalTestConfig()
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha"})
+ seedCanonicalLookup(t, db, "alpha_kind", "Bravo-Widget-Canonical",
+ []string{"Bravo-Widget-Canonical", "Bravo"})
+
+ // Teach the playbook against the Alpha query.
+ alphaQuery := "report Alpha widget today"
+ family := QueryFamily(PromoteEntities(
+ Normalize(alphaQuery, cfg),
+ NewCanonicalResolver(context.Background(), db),
+ ))
+ playbookJSON := `{
+ "query_family_examples": ["report Alpha widget today"],
+ "entity_slots": ["$ENTITY"],
+ "expected_tool_calls": 2,
+ "steps": [
+ {"cmd": "example-pp-cli items list --query {$ENTITY.canonical}"}
+ ]
+ }`
+ seedPlaybookRow(t, db, family, playbookJSON, "Endpoint envelope varies by upstream.\n")
+
+ // Bravo query MUST hit the same playbook.
+ got, err := Recall(context.Background(), db, "report Bravo widget today", Opts{EntityConfig: cfg})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Playbook == nil {
+ t.Fatalf("cross-entity replay failed: want Playbook on envelope, got nil. Result=%+v", got)
+ }
+ if got.Playbook.QueryFamily != family {
+ t.Errorf("Playbook.QueryFamily = %q, want %q", got.Playbook.QueryFamily, family)
+ }
+ // Slot must bind to Bravo's canonical, not Alpha's.
+ slot := got.Playbook.SlotsResolved["$ENTITY"]
+ if slot == nil {
+ t.Fatalf("want $ENTITY slot resolved on Bravo query; got SlotsResolved=%v",
+ got.Playbook.SlotsResolved)
+ }
+ if canonical, ok := slot["canonical"].(string); !ok || canonical != "Bravo-Widget-Canonical" {
+ t.Errorf("$ENTITY.canonical = %v, want Bravo-Widget-Canonical (slot must bind to query entity, not playbook author's)", slot)
+ }
+}
+
+// TestRecall_PlaybookAbsent_NoMatch covers the negative case: a query
+// whose family has no stored playbook returns envelope.Playbook = nil
+// and no error. Notes also empty.
+func TestRecall_PlaybookAbsent_NoMatch(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ // No playbook rows seeded.
+ got, err := Recall(context.Background(), db, "some unrelated query", Opts{EntityConfig: canonicalTestConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Playbook != nil {
+ t.Errorf("want envelope.Playbook = nil when family has no row; got %+v", got.Playbook)
+ }
+ if got.Notes != "" {
+ t.Errorf("want envelope.Notes empty; got %q", got.Notes)
+ }
+}
+
+// TestRecall_PlaybookNotesOnly covers the notes-only row: a stored
+// playbook with empty playbook_json but populated notes_text surfaces
+// Notes on the envelope; the Playbook wrapper is still attached so the
+// agent gets the guidance even without structured choreography. The
+// embedded Playbook value has zero steps.
+func TestRecall_PlaybookNotesOnly(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ cfg := canonicalTestConfig()
+ query := "guidance workaround warning"
+ family := QueryFamily(PromoteEntities(
+ Normalize(query, cfg),
+ NewCanonicalResolver(context.Background(), db),
+ ))
+ notes := "Upstream rollups don't carry per-entity rows; always drill to children.\n"
+ seedPlaybookRow(t, db, family, "", notes)
+
+ got, err := Recall(context.Background(), db, query, Opts{EntityConfig: cfg})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Notes != notes {
+ t.Errorf("Notes = %q, want %q", got.Notes, notes)
+ }
+ if got.Playbook == nil {
+ t.Fatalf("notes-only row should still attach Playbook wrapper carrying the notes; got nil")
+ }
+ if len(got.Playbook.Playbook.Steps) != 0 {
+ t.Errorf("notes-only row should have zero steps; got %d", len(got.Playbook.Playbook.Steps))
+ }
+ if got.Playbook.Notes != notes {
+ t.Errorf("ResolvedPlaybook.Notes = %q, want %q", got.Playbook.Notes, notes)
+ }
+}
+
+// TestRecall_PlaybookCoexistsWithResourceHit confirms playbooks land
+// on the envelope orthogonally to per-resource hits. A query that hits
+// both a search_learnings row AND has a stored playbook for its family
+// returns both surfaces populated.
+func TestRecall_PlaybookCoexistsWithResourceHit(t *testing.T) {
+ t.Parallel()
+ db := openRecallCanonicalTestDB(t)
+ cfg := canonicalTestConfig()
+ seedCanonicalLookup(t, db, "alpha_kind", "Alpha-Widget-Canonical",
+ []string{"Alpha-Widget-Canonical", "Alpha"})
+
+ query := "report Alpha widget today"
+ // Seed a direct learning that matches.
+ seedCanonicalLearning(t, db, "report alpha widget today", `["Alpha"]`,
+ "A1-RESOURCE", "widgets")
+ // Seed a playbook for the same family.
+ family := QueryFamily(PromoteEntities(
+ Normalize(query, cfg),
+ NewCanonicalResolver(context.Background(), db),
+ ))
+ playbookJSON := `{
+ "query_family_examples": ["report Alpha widget today"],
+ "entity_slots": ["$ENTITY"],
+ "steps": [{"cmd": "example-pp-cli items get A1-{$ENTITY.canonical}"}]
+ }`
+ seedPlaybookRow(t, db, family, playbookJSON, "Drill to child item.\n")
+
+ got, err := Recall(context.Background(), db, query, Opts{EntityConfig: cfg})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found {
+ t.Errorf("want Found=true (direct learning hit); got Result=%+v", got)
+ }
+ if len(got.Results) == 0 {
+ t.Errorf("want >=1 result from search_learnings; got 0")
+ }
+ if got.Playbook == nil {
+ t.Errorf("want envelope.Playbook attached alongside results; got nil")
+ }
+ if got.Notes == "" {
+ t.Errorf("want envelope.Notes populated alongside results; got empty")
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/recall_test.go b/library/productivity/human-goat/internal/learn/recall_test.go
new file mode 100644
index 0000000000..912a410c6e
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/recall_test.go
@@ -0,0 +1,766 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+func openRecallTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "recall.db")
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ for _, q := range []string{
+ `CREATE TABLE resources (
+ resource_type TEXT NOT NULL,
+ id TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`,
+ `CREATE TABLE search_learnings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_pattern TEXT NOT NULL,
+ query_entities TEXT,
+ venue TEXT,
+ resource_type TEXT,
+ resource_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ alias_target TEXT,
+ source TEXT NOT NULL,
+ confidence INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ notes TEXT
+ )`,
+ `CREATE INDEX idx_learn_query ON search_learnings(query_pattern)`,
+ `CREATE UNIQUE INDEX idx_learn_unique ON search_learnings(query_pattern, resource_id, action)`,
+ `CREATE TABLE search_patterns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_template TEXT NOT NULL,
+ resource_template TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ venue TEXT,
+ strategy TEXT NOT NULL,
+ entity_kind TEXT NOT NULL,
+ confidence INTEGER NOT NULL DEFAULT 2,
+ source TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ example_query TEXT,
+ example_resource TEXT
+ )`,
+ `CREATE UNIQUE INDEX idx_patterns_unique ON search_patterns(query_template, resource_template, strategy)`,
+ `CREATE TABLE entity_lookups (
+ kind TEXT NOT NULL,
+ canonical TEXT NOT NULL,
+ value TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'seeded',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (kind, canonical, value)
+ )`,
+ `CREATE TABLE learning_playbooks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_family TEXT NOT NULL UNIQUE,
+ playbook_json TEXT,
+ notes_text TEXT,
+ source TEXT NOT NULL DEFAULT 'taught',
+ confidence INTEGER NOT NULL DEFAULT 2,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at TIMESTAMP
+ )`,
+ `CREATE TABLE learn_candidates (
+ id INTEGER PRIMARY KEY,
+ class TEXT NOT NULL CHECK(class IN ('flag_alias','playbook_candidate')),
+ payload TEXT NOT NULL,
+ derivation_signature TEXT NOT NULL UNIQUE,
+ sightings INTEGER NOT NULL DEFAULT 1,
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','confirmed','rejected','expired')),
+ query_family TEXT,
+ command_path TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL
+ )`,
+ } {
+ if _, err := db.Exec(q); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ }
+ return db
+}
+
+func seedRecallResource(t *testing.T, db *sql.DB, rt, id, data string) {
+ t.Helper()
+ if _, err := db.Exec(`INSERT INTO resources (resource_type, id, data) VALUES (?, ?, ?)`, rt, id, data); err != nil {
+ t.Fatalf("seed resource: %v", err)
+ }
+}
+
+func seedLearning(t *testing.T, db *sql.DB, qp, entitiesJSON, resourceID, rt, action string, confidence int) {
+ t.Helper()
+ if _, err := db.Exec(`INSERT INTO search_learnings (query_pattern, query_entities, resource_id, resource_type, venue, action, confidence, source, last_observed_at)
+ VALUES (?, ?, ?, ?, '', ?, ?, 'taught', CURRENT_TIMESTAMP)`,
+ qp, entitiesJSON, resourceID, rt, action, confidence); err != nil {
+ t.Fatalf("seed learning: %v", err)
+ }
+}
+
+func TestRecall_HappyPath(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-AL", `{"title":"Alpha widget"}`)
+ // Stored query_pattern is the non-entity normalized form; query_entities
+ // holds the case-preserving entity tokens.
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{
+ EntityConfig: testConfig(),
+ ResourceTypeFields: map[string][]string{"widgets": {"title"}},
+ })
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found {
+ t.Fatalf("want Found=true, got %+v", got)
+ }
+ if len(got.Results) != 1 {
+ t.Fatalf("want 1 hit, got %d", len(got.Results))
+ }
+ if got.Results[0].ResourceID != "PREFIX-AL" {
+ t.Errorf("ResourceID = %q, want PREFIX-AL", got.Results[0].ResourceID)
+ }
+}
+
+func TestRecall_Miss(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ got, err := Recall(context.Background(), db, "totally unrelated query", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Found {
+ t.Errorf("want Found=false, got %+v", got)
+ }
+ found := false
+ for _, w := range got.Warnings {
+ if w == TopWarningNoLearningsForQueryFamily {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("missing %q warning; got %v", TopWarningNoLearningsForQueryFamily, got.Warnings)
+ }
+}
+
+func TestRecall_NoLearnReturnsEmpty(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{EntityConfig: testConfig(), NoLearn: true})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Found {
+ t.Errorf("want Found=false with NoLearn, got %+v", got)
+ }
+}
+
+func TestRecall_ConfidenceFloor(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 1)
+ // MinConfidence=3 should drop the row.
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{EntityConfig: testConfig(), MinConfidence: 3})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Found {
+ t.Errorf("want Found=false at MinConfidence=3, got %+v", got)
+ }
+}
+
+func TestRecall_EntityMismatchFiltersToMismatches(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-AL", `{"title":"Alpha widget"}`)
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+ // Query a different entity entirely.
+ got, err := Recall(context.Background(), db, "Bravo widget", Opts{
+ EntityConfig: testConfig(),
+ DebugMismatches: true,
+ ResourceTypeFields: map[string][]string{"widgets": {"title"}},
+ })
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Found {
+ t.Errorf("want Found=false on entity mismatch; got %+v", got)
+ }
+ if len(got.Mismatches) != 1 {
+ t.Fatalf("want 1 mismatch, got %d: %+v", len(got.Mismatches), got.Mismatches)
+ }
+}
+
+func TestRecall_DebugMismatchesGate(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-AL", `{"title":"Alpha widget"}`)
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+ got, err := Recall(context.Background(), db, "Bravo widget", Opts{
+ EntityConfig: testConfig(),
+ ResourceTypeFields: map[string][]string{"widgets": {"title"}},
+ })
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if len(got.Mismatches) != 0 {
+ t.Errorf("Mismatches should be hidden when DebugMismatches=false; got %v", got.Mismatches)
+ }
+}
+
+func TestRecall_LowConfidenceWarning(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-AL", `{"title":"Alpha widget"}`)
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 1)
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{
+ EntityConfig: testConfig(),
+ ResourceTypeFields: map[string][]string{"widgets": {"title"}},
+ })
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found || len(got.Results) != 1 {
+ t.Fatalf("want 1 hit, got %+v", got)
+ }
+ found := false
+ for _, w := range got.Results[0].Warnings {
+ if w == WarningLowConfidence {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("missing low_confidence warning on confidence=1 row; got %v", got.Results[0].Warnings)
+ }
+}
+
+func TestRecall_ResourceNotInStoreWarning(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ // No seeded resource; the recall still finds the learning row.
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found {
+ t.Fatalf("want Found=true (resource missing but learning is high-confidence partial)")
+ }
+ found := false
+ for _, w := range got.Results[0].Warnings {
+ if w == WarningResourceNotInStore {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("missing resource_not_in_store warning; got %v", got.Results[0].Warnings)
+ }
+}
+
+// TestRecall_ColdUnresolvableEntityWarnsRunSyncAndRecordsMiss covers
+// the staleness-heal capture side: a cold recall whose entity has no
+// entity_lookups row under any source (1) surfaces the stateless
+// lookup_refresh_available warning naming `sync`, and (2) records the
+// entity in the learn_recall_misses table the post-sync scanner
+// filters on.
+func TestRecall_ColdUnresolvableEntityWarnsRunSyncAndRecordsMiss(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+
+ got, err := Recall(context.Background(), db, "standings for Zephyrium", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if got.Found {
+ t.Fatalf("want cold recall (Found=false); got %+v", got)
+ }
+
+ var sawNoLearnings, sawRefresh bool
+ for _, w := range got.Warnings {
+ if w == TopWarningNoLearningsForQueryFamily {
+ sawNoLearnings = true
+ }
+ if strings.HasPrefix(w, TopWarningLookupRefreshAvailable+":") {
+ sawRefresh = true
+ if !strings.Contains(w, "Zephyrium") {
+ t.Errorf("refresh warning should name the entity; got %q", w)
+ }
+ if !strings.Contains(w, "sync") {
+ t.Errorf("refresh warning text must name running sync; got %q", w)
+ }
+ }
+ }
+ if !sawNoLearnings {
+ t.Errorf("missing %q warning; got %v", TopWarningNoLearningsForQueryFamily, got.Warnings)
+ }
+ if !sawRefresh {
+ t.Errorf("missing %q warning; got %v", TopWarningLookupRefreshAvailable, got.Warnings)
+ }
+
+ var entity string
+ if err := db.QueryRow(`SELECT entity FROM learn_recall_misses`).Scan(&entity); err != nil {
+ t.Fatalf("recall did not record the lookup miss: %v", err)
+ }
+ if entity != "zephyrium" {
+ t.Errorf("recorded miss = %q, want lowercased %q", entity, "zephyrium")
+ }
+}
+
+// TestRecall_SyncedLookupRowPromotesPreviouslyMissedEntity is the heal
+// half of the incident replay: after a synced-source row lands (what
+// the post-sync scanner writes), the same recall that previously
+// warned lookup_refresh_available now resolves and promotes the
+// entity — even from its lowercase form, which the capitalization-
+// based extractor alone cannot catch.
+func TestRecall_SyncedLookupRowPromotesPreviouslyMissedEntity(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ if _, err := db.Exec(`INSERT INTO entity_lookups (kind, canonical, value, source)
+ VALUES ('events', 'Zephyrium', 'Zephyrium', 'synced')`); err != nil {
+ t.Fatalf("insert synced lookup row: %v", err)
+ }
+
+ got, err := Recall(context.Background(), db, "standings for zephyrium", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+
+ promoted := false
+ for _, e := range got.QueryEntities {
+ if strings.EqualFold(e, "zephyrium") {
+ promoted = true
+ break
+ }
+ }
+ if !promoted {
+ t.Errorf("synced lookup row should promote the entity; QueryEntities = %v", got.QueryEntities)
+ }
+ for _, w := range got.Warnings {
+ if strings.HasPrefix(w, TopWarningLookupRefreshAvailable+":") {
+ t.Errorf("resolved entity must not surface %q; got %v", TopWarningLookupRefreshAvailable, got.Warnings)
+ }
+ }
+
+ // A resolvable entity is not a lookup miss: nothing recorded.
+ var n int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'learn_recall_misses'`).Scan(&n); err != nil {
+ t.Fatalf("sqlite_master: %v", err)
+ }
+ if n != 0 {
+ var misses int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM learn_recall_misses`).Scan(&misses); err != nil {
+ t.Fatalf("count misses: %v", err)
+ }
+ if misses != 0 {
+ t.Errorf("resolved entity recorded %d miss rows, want 0", misses)
+ }
+ }
+}
+
+// candidateSeed is one learn_candidates fixture row. Zero values fall
+// back to a well-formed open flag_alias observation so tests only spell
+// out the fields they exercise.
+type candidateSeed struct {
+ id int64
+ class string
+ payload string
+ signature string
+ status string
+ queryFamily string
+ commandPath string
+ sightings int
+ lastSeen string
+}
+
+func seedCandidate(t *testing.T, db *sql.DB, c candidateSeed) {
+ t.Helper()
+ if c.class == "" {
+ c.class = "flag_alias"
+ }
+ if c.payload == "" {
+ c.payload = `{"command_path":"widgets list","failed_flag":"--date","corrected_flag":"--dates"}`
+ }
+ if c.signature == "" {
+ c.signature = fmt.Sprintf("sig-%d", c.id)
+ }
+ if c.status == "" {
+ c.status = "open"
+ }
+ if c.sightings == 0 {
+ c.sightings = 1
+ }
+ if c.lastSeen == "" {
+ c.lastSeen = "2026-06-01T00:00:00Z"
+ }
+ if _, err := db.Exec(`INSERT INTO learn_candidates
+ (id, class, payload, derivation_signature, sightings, status, query_family, command_path, created_at, updated_at, last_seen_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ c.id, c.class, c.payload, c.signature, c.sightings, c.status,
+ c.queryFamily, c.commandPath, c.lastSeen, c.lastSeen, c.lastSeen); err != nil {
+ t.Fatalf("seed candidate: %v", err)
+ }
+}
+
+// seedPlaybookRow (learning_playbooks fixture insert) is shared with
+// recall_canonical_test.go, which declares it.
+
+func hasWarning(warnings []string, code string) bool {
+ for _, w := range warnings {
+ if w == code {
+ return true
+ }
+ }
+ return false
+}
+
+// TestRecall_OpenCandidateSurfacesWithTwoStepNextAction covers the
+// happy half of the candidates surface: one open family-anchored
+// candidate rides the envelope in its own `candidates` section — the
+// verified results stay untouched — with exactly two byte-exact
+// next-action steps (the trial invocation, then the literal
+// `learnings confirm ` command) and a plain-words rationale.
+func TestRecall_OpenCandidateSurfacesWithTwoStepNextAction(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-AL", `{"title":"Alpha widget"}`)
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+ // "Alpha widget" normalizes to family "widget"; anchor the candidate there.
+ seedCandidate(t, db, candidateSeed{id: 7, queryFamily: "widget", commandPath: "widgets list", sightings: 2})
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{
+ EntityConfig: testConfig(),
+ ResourceTypeFields: map[string][]string{"widgets": {"title"}},
+ })
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+
+ // Verified surfaces are untouched: the hit is still a hit, and no
+ // candidate is interleaved into results.
+ if !got.Found || len(got.Results) != 1 || got.Results[0].ResourceID != "PREFIX-AL" {
+ t.Fatalf("verified results must be untouched by candidates; got %+v", got)
+ }
+
+ if len(got.Candidates) != 1 {
+ t.Fatalf("want 1 candidate, got %d: %+v", len(got.Candidates), got.Candidates)
+ }
+ c := got.Candidates[0]
+ if c.ID != 7 || c.Class != "flag_alias" || c.Sightings != 2 {
+ t.Errorf("candidate row fields wrong: %+v", c)
+ }
+ if !strings.Contains(c.Summary, "--dates") || !strings.Contains(c.Summary, "--date") {
+ t.Errorf("summary should name the correction compactly; got %q", c.Summary)
+ }
+ if !strings.Contains(c.Rationale, "seen 2 times") {
+ t.Errorf("rationale must count sightings in plain words; got %q", c.Rationale)
+ }
+ if !strings.Contains(c.Rationale, "family") {
+ t.Errorf("family-anchored rationale should say why it surfaced; got %q", c.Rationale)
+ }
+ if c.LastSeen.IsZero() {
+ t.Errorf("last_seen must carry the stored timestamp")
+ }
+
+ if len(c.NextAction) != 2 {
+ t.Fatalf("next_action must be exactly two steps; got %v", c.NextAction)
+ }
+ // Step 1: the corrected trial invocation, composed against the
+ // installed binary name.
+ for _, want := range []string{candidateCommandBinary, "widgets list", "--dates"} {
+ if !strings.Contains(c.NextAction[0], want) {
+ t.Errorf("trial step missing %q; got %q", want, c.NextAction[0])
+ }
+ }
+ // Step 2: byte-exact confirm command matching the registered
+ // `learnings confirm` control command.
+ wantConfirm := candidateCommandBinary + " learnings confirm 7"
+ if c.NextAction[1] != wantConfirm {
+ t.Errorf("confirm step = %q, want %q", c.NextAction[1], wantConfirm)
+ }
+
+ if !hasWarning(got.Warnings, TopWarningCandidatesPresent) {
+ t.Errorf("missing %q warning with a non-empty candidates section; got %v", TopWarningCandidatesPresent, got.Warnings)
+ }
+}
+
+// TestRecall_CandidatesCapAtThreeDeterministicOrder pins the cap and
+// the ranking contract: family-anchored first, then sightings, then
+// recency — a high-sighting unconfirmed candidate must outrank a fresh
+// low-sighting one (the no-confirm steady state depends on it).
+func TestRecall_CandidatesCapAtThreeDeterministicOrder(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ for _, c := range []candidateSeed{
+ {id: 1, queryFamily: "widget", sightings: 1, lastSeen: "2026-02-01T00:00:00Z"},
+ {id: 2, sightings: 9, lastSeen: "2026-01-02T00:00:00Z"},
+ {id: 3, queryFamily: "unrelated family", sightings: 5, lastSeen: "2026-06-30T00:00:00Z"},
+ {id: 4, sightings: 5, lastSeen: "2026-01-01T00:00:00Z"},
+ // Freshest row, lowest sightings: must NOT displace the
+ // high-sighting rows above it.
+ {id: 5, sightings: 1, lastSeen: "2026-07-01T00:00:00Z"},
+ } {
+ seedCandidate(t, db, c)
+ }
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if len(got.Candidates) != 3 {
+ t.Fatalf("cap is 3; got %d: %+v", len(got.Candidates), got.Candidates)
+ }
+ wantOrder := []int64{1, 2, 3}
+ for i, want := range wantOrder {
+ if got.Candidates[i].ID != want {
+ t.Errorf("candidates[%d].ID = %d, want %d (full order %+v)", i, got.Candidates[i].ID, want, got.Candidates)
+ }
+ }
+}
+
+// TestRecall_ConfirmedCandidateArrivesViaPlaybookNotCandidates pins
+// the post-confirm contract in one shot: the confirmed row never
+// reappears in candidates[], and its materialized artifact reaches the
+// agent through the normal playbook/notes surface instead.
+func TestRecall_ConfirmedCandidateArrivesViaPlaybookNotCandidates(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedPlaybookRow(t, db, "widget", "", "confirmed guidance")
+ seedCandidate(t, db, candidateSeed{
+ id: 9,
+ class: "playbook_candidate",
+ payload: `{"query_family":"widget","notes_text":"confirmed guidance"}`,
+ status: "confirmed",
+ queryFamily: "widget",
+ })
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if len(got.Candidates) != 0 {
+ t.Errorf("confirmed candidates must never surface in candidates[]; got %+v", got.Candidates)
+ }
+ if hasWarning(got.Warnings, TopWarningCandidatesPresent) {
+ t.Errorf("no %q warning without open candidates; got %v", TopWarningCandidatesPresent, got.Warnings)
+ }
+ if got.Playbook == nil || got.Notes != "confirmed guidance" {
+ t.Errorf("the materialized artifact must arrive via the playbook/notes path; got playbook=%+v notes=%q", got.Playbook, got.Notes)
+ }
+}
+
+// TestRecall_RejectedAndExpiredCandidatesNeverSurface: settled rows of
+// either terminal-ish status stay out of the envelope.
+func TestRecall_RejectedAndExpiredCandidatesNeverSurface(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedCandidate(t, db, candidateSeed{id: 11, status: "rejected", queryFamily: "widget"})
+ seedCandidate(t, db, candidateSeed{id: 12, status: "expired", queryFamily: "widget"})
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if len(got.Candidates) != 0 {
+ t.Errorf("rejected/expired rows must never surface; got %+v", got.Candidates)
+ }
+ if hasWarning(got.Warnings, TopWarningCandidatesPresent) {
+ t.Errorf("no %q warning without open candidates; got %v", TopWarningCandidatesPresent, got.Warnings)
+ }
+}
+
+// TestRecall_NoCandidatesOmitsKeyAndWarning pins the empty case:
+// with nothing open the serialized envelope has no `candidates` key at
+// all and no candidates_present warning — byte-stable against the
+// pre-candidates shape.
+func TestRecall_NoCandidatesOmitsKeyAndWarning(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-AL", `{"title":"Alpha widget"}`)
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{
+ EntityConfig: testConfig(),
+ ResourceTypeFields: map[string][]string{"widgets": {"title"}},
+ })
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ raw, err := json.Marshal(got)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if strings.Contains(string(raw), `"candidates"`) {
+ t.Errorf("empty case must omit the candidates key entirely; got %s", raw)
+ }
+ if hasWarning(got.Warnings, TopWarningCandidatesPresent) {
+ t.Errorf("no %q warning in the empty case; got %v", TopWarningCandidatesPresent, got.Warnings)
+ }
+}
+
+// TestRecall_CaptureDisabledStillSurfacesCandidates: the
+// capture-specific env var kills journaling and derivation only;
+// surfacing existing candidates is a read-only path and must keep
+// working. (No t.Parallel: t.Setenv.)
+func TestRecall_CaptureDisabledStillSurfacesCandidates(t *testing.T) {
+ t.Setenv(journalNoCaptureEnvVar, "1")
+ db := openRecallTestDB(t)
+ seedCandidate(t, db, candidateSeed{id: 21, queryFamily: "widget", sightings: 3})
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if len(got.Candidates) != 1 || got.Candidates[0].ID != 21 {
+ t.Errorf("capture-disabled env must not hide existing candidates; got %+v", got.Candidates)
+ }
+ if !hasWarning(got.Warnings, TopWarningCandidatesPresent) {
+ t.Errorf("missing %q warning; got %v", TopWarningCandidatesPresent, got.Warnings)
+ }
+}
+
+// TestRecall_PlaybookCommandOverlapCandidateOutranksGlobal covers the
+// middle ranking tier: a candidate whose command path appears in the
+// resolved playbook's steps outranks an unanchored higher-sighting
+// row.
+func TestRecall_PlaybookCommandOverlapCandidateOutranksGlobal(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedPlaybookRow(t, db, "widget",
+ `{"steps":[{"cmd":"widgets list --dates {date}","purpose":"list widgets"}]}`,
+ "use the dates flag")
+ seedCandidate(t, db, candidateSeed{id: 31, commandPath: "widgets list", sightings: 1})
+ seedCandidate(t, db, candidateSeed{id: 32, commandPath: "other thing", sightings: 9})
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{EntityConfig: testConfig()})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if len(got.Candidates) != 2 {
+ t.Fatalf("want both candidates, got %+v", got.Candidates)
+ }
+ if got.Candidates[0].ID != 31 || got.Candidates[1].ID != 32 {
+ t.Errorf("playbook command-path overlap must outrank sightings; got order %+v", got.Candidates)
+ }
+ if !strings.Contains(got.Candidates[0].Rationale, "playbook") {
+ t.Errorf("overlap rationale should say why in plain words; got %q", got.Candidates[0].Rationale)
+ }
+}
+
+// TestRecall_HitCarriesLearningID pins the row-id linkage the
+// measurement layer joins on: every direct hit exposes the backing
+// search_learnings row id, and the command side records it as the
+// recall_hit event's matched_row_id.
+func TestRecall_HitCarriesLearningID(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-AL", `{"title":"Alpha widget"}`)
+ seedLearning(t, db, "widget", `["Alpha"]`, "PREFIX-AL", "widgets", "boost", 3)
+
+ var wantID int64
+ if err := db.QueryRow(`SELECT id FROM search_learnings WHERE resource_id = 'PREFIX-AL'`).Scan(&wantID); err != nil {
+ t.Fatalf("read seeded id: %v", err)
+ }
+
+ got, err := Recall(context.Background(), db, "Alpha widget", Opts{
+ EntityConfig: testConfig(),
+ ResourceTypeFields: map[string][]string{"widgets": {"title"}},
+ })
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if !got.Found || len(got.Results) != 1 {
+ t.Fatalf("want 1 hit, got %+v", got)
+ }
+ if got.Results[0].LearningID != wantID {
+ t.Errorf("LearningID = %d, want %d", got.Results[0].LearningID, wantID)
+ }
+ if got.Family == "" {
+ t.Error("Result.Family should carry the structural family key for event instrumentation")
+ }
+}
+
+// TestFamilyHash_StableNonReversibleEmpty pins the event-key contract:
+// deterministic, distinct across families, no family text leaking
+// through, and "" for an empty family (stored as NULL).
+func TestFamilyHash_StableNonReversibleEmpty(t *testing.T) {
+ t.Parallel()
+ a1 := FamilyHash("orders pending show")
+ a2 := FamilyHash("orders pending show")
+ b := FamilyHash("orders list show")
+ if a1 == "" || a1 != a2 {
+ t.Errorf("FamilyHash must be deterministic; got %q vs %q", a1, a2)
+ }
+ if a1 == b {
+ t.Errorf("distinct families must hash apart; both %q", a1)
+ }
+ if strings.Contains(a1, "orders") {
+ t.Errorf("hash must not carry family text; got %q", a1)
+ }
+ if FamilyHash("") != "" || FamilyHash(" ") != "" {
+ t.Error("empty family must hash to \"\" (stored as NULL)")
+ }
+}
+
+// TestScanPII_EmailAndPhoneShapes pins the R18 insert-time guard's
+// vocabulary: obvious email/phone shapes match by rule name, and
+// bare numeric identifiers stay clean (the conservative side).
+func TestScanPII_EmailAndPhoneShapes(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ text string
+ want []string
+ }{
+ {"clean structural query", "list pending orders for the northwest region", nil},
+ {"email", "orders placed by jane.doe@example.com yesterday", []string{PIIRuleEmail}},
+ {"email with plus tag", "tickets from support+urgent@example.co.uk", []string{PIIRuleEmail}},
+ {"dashed phone", "call the customer at 555-123-4567 about the order", []string{PIIRulePhone}},
+ {"paren phone", "contact (555) 123-4567 for delivery", []string{PIIRulePhone}},
+ {"international phone", "reach them at +1 555 123 4567", []string{PIIRulePhone}},
+ {"both", "email a@b.io or call 555-123-4567", []string{PIIRuleEmail, PIIRulePhone}},
+ {"bare numeric id stays clean", "status of order 5551234567", nil},
+ {"date stays clean", "orders between 2026-07-01 and 2026-07-02", nil},
+ {"version stays clean", "changes in release 1.2.3", nil},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := ScanPII(tc.text)
+ if len(got) != len(tc.want) {
+ t.Fatalf("ScanPII(%q) = %v, want %v", tc.text, got, tc.want)
+ }
+ for i := range tc.want {
+ if got[i] != tc.want[i] {
+ t.Errorf("ScanPII(%q)[%d] = %q, want %q", tc.text, i, got[i], tc.want[i])
+ }
+ }
+ })
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/synthesize.go b/library/productivity/human-goat/internal/learn/synthesize.go
new file mode 100644
index 0000000000..03d4f3a68d
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/synthesize.go
@@ -0,0 +1,307 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// Teach-time playbook synthesis. When an agent teaches a resource
+// mapping for a query family that still lacks a playbook, this
+// session's journal already recorded the discovery choreography the
+// agent actually ran between its recall miss and the teach.
+// SynthesizePlaybookCandidate turns that episode into a quarantined
+// playbook_candidate row in learn_candidates. Nothing here writes
+// learning_playbooks: materializing a candidate at the confidence-2
+// floor is the explicit `learnings confirm` command's job, so a wrong
+// synthesis can never alter recall's verified results.
+
+// synthesisStepSkipSet names command path segments that never become
+// synthesized playbook steps. Two groups share the one set so the
+// window walk consults a single place:
+//
+// - the learn-command family (recall, teach, teach-playbook,
+// teach-pattern, teach-lookup, learnings, playbook): the loop's
+// own bookkeeping is not discovery choreography;
+// - framework commands, mirroring learnHookSkipList in the emitted
+// internal/cli/root.go (root.go.tmpl owns that canonical list;
+// keep this mirror in sync when the framework surface changes).
+var synthesisStepSkipSet = map[string]struct{}{
+ // Learn-command family.
+ "recall": {},
+ "teach": {},
+ "teach-playbook": {},
+ "teach-pattern": {},
+ "teach-lookup": {},
+ "learnings": {},
+ "playbook": {},
+ // Framework commands (learnHookSkipList mirror).
+ "auth": {},
+ "doctor": {},
+ "help": {},
+ "sync": {},
+ "profile": {},
+ "feedback": {},
+ "which": {},
+ "agent-context": {},
+ "completion": {},
+ "version": {},
+}
+
+// synthesisStepExcluded reports whether any segment of a resolved verb
+// chain disqualifies the invocation from becoming a step. Any-segment
+// matching mirrors shouldSkipLearnHook's CommandPath semantics.
+func synthesisStepExcluded(cmd []string) bool {
+ for _, segment := range cmd {
+ if _, skip := synthesisStepSkipSet[segment]; skip {
+ return true
+ }
+ }
+ return false
+}
+
+// synthesisIsRecall reports whether a journal entry's verb chain is
+// the recall command — the episode anchor and the interleave signal.
+func synthesisIsRecall(cmd []string) bool {
+ return len(cmd) > 0 && cmd[0] == "recall"
+}
+
+// synthesisCandidatePayload matches the playbook_candidate payload
+// contract the `learnings confirm` materializer consumes
+// (playbookCandidatePayload in the emitted internal/cli package):
+// query_family anchors the row, playbook_json carries the canonical
+// Playbook shape, notes_text the free-text observations.
+type synthesisCandidatePayload struct {
+ QueryFamily string `json:"query_family"`
+ PlaybookJSON string `json:"playbook_json"`
+ NotesText string `json:"notes_text,omitempty"`
+}
+
+// synthesisSignature is the derivation signature for a synthesized
+// candidate: (class, query_family). One family, one synthesis row —
+// repeat episodes bump sightings on the same signature, and a rejected
+// signature tombstones all future synthesis for the family.
+func synthesisSignature(queryFamily string) string {
+ return store.CandidateClassPlaybookCandidate + "|" + queryFamily
+}
+
+// synthesisEpisode is one extracted recall-to-teach window: the
+// ordered successful discovery steps, any observed flag corrections,
+// and the first step's verb chain as the candidate's command-path
+// anchor.
+type synthesisEpisode struct {
+ steps []PlaybookStep
+ notes []string
+ commandPath string
+}
+
+// extractSynthesisEpisode walks the journal for the episode belonging
+// to (queryFamily, sessionKey): the window from the LAST recall of
+// that family under that session key to the end of the journal. The
+// teach itself is never in the window — its journal entry is written
+// post-run, after the RunE this is called from. Returns ok=false when
+// the episode cannot be trusted:
+//
+// - no recall entry for the family exists under the session key;
+// - a window entry carries no session key at all (the window is not
+// consistently keyed, so command attribution is unreliable);
+// - the window contains a recall for a DIFFERENT family under the
+// same session key (foreign-recall interleave: the session
+// switched tasks mid-window, so the steps are not one discovery).
+//
+// Entries under a different non-empty session key are a concurrent
+// other session, not an interleave; they are skipped, not fatal.
+func extractSynthesisEpisode(entries []JournalEntry, queryFamily, sessionKey string) (synthesisEpisode, bool) {
+ start := -1
+ for i, e := range entries {
+ if e.SessionKey == sessionKey && synthesisIsRecall(e.Cmd) && e.QueryFamily == queryFamily {
+ start = i
+ }
+ }
+ if start < 0 {
+ return synthesisEpisode{}, false
+ }
+ var ep synthesisEpisode
+ for _, e := range entries[start+1:] {
+ if strings.TrimSpace(e.SessionKey) == "" {
+ return synthesisEpisode{}, false
+ }
+ if e.SessionKey != sessionKey {
+ continue
+ }
+ if synthesisIsRecall(e.Cmd) && e.QueryFamily != "" && e.QueryFamily != queryFamily {
+ return synthesisEpisode{}, false
+ }
+ // A journaled failed/suggested flag pair is a correction the
+ // session already paid for; carry it as a candidate note so the
+ // confirmed playbook warns the next session up front. Normalize
+ // both tokens to a single-dash form (the journal spelling
+ // varies) so the note never double-dashes a flag.
+ if e.FailedFlag != "" && e.SuggestedFlag != "" {
+ ep.notes = append(ep.notes, fmt.Sprintf(
+ "observed in-session correction: use %s instead of %s",
+ normalizeFlagToken(e.SuggestedFlag), normalizeFlagToken(e.FailedFlag)))
+ }
+ if e.ExitCode != 0 || len(e.Cmd) == 0 || synthesisStepExcluded(e.Cmd) {
+ continue
+ }
+ if ep.commandPath == "" {
+ ep.commandPath = strings.Join(e.Cmd, " ")
+ }
+ ep.steps = append(ep.steps, PlaybookStep{Cmd: synthesisStepCmd(e)})
+ }
+ return ep, true
+}
+
+// synthesisStepCmd renders one journal entry as a playbook step: the
+// resolved verb chain plus each observed flag with a value-class slot
+// hint. Flag values were never journaled (only their classes), so the
+// hints are placeholders like and ; boolean flags carry no
+// placeholder. Flags render sorted so the same episode always
+// synthesizes byte-identical payloads.
+func synthesisStepCmd(e JournalEntry) string {
+ parts := append([]string(nil), e.Cmd...)
+ names := make([]string, 0, len(e.ArgvShape))
+ for name := range e.ArgvShape {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ if e.ArgvShape[name] == journalClassBool {
+ parts = append(parts, "--"+name)
+ continue
+ }
+ parts = append(parts, "--"+name, "<"+e.ArgvShape[name]+">")
+ }
+ return strings.Join(parts, " ")
+}
+
+// synthesisStepCount parses a stored candidate payload and reports how
+// many steps its playbook carries. Unparseable payloads report MaxInt
+// so any valid fresh episode supersedes them.
+func synthesisStepCount(payload string) int {
+ var p synthesisCandidatePayload
+ if err := json.Unmarshal([]byte(payload), &p); err != nil {
+ return math.MaxInt
+ }
+ pb, err := ParsePlaybook([]byte(p.PlaybookJSON), "candidate payload")
+ if err != nil {
+ return math.MaxInt
+ }
+ return len(pb.Steps)
+}
+
+// refreshShorterSynthesis overwrites an open candidate's payload when
+// the fresh episode has strictly fewer steps than the stored one — a
+// tighter choreography supersedes a noisier first capture, never the
+// reverse. Direct SQL on the store handle: the candidate API has no
+// payload update on purpose (sightings accumulate via DeriveCandidate)
+// and only synthesis carries a supersede rule. The status guard keeps
+// the write off any row `learnings confirm`/`reject` settled between
+// the derive and this update.
+func refreshShorterSynthesis(s *store.Store, row store.CandidateRow, payload string, stepCount int) (bool, error) {
+ if stepCount >= synthesisStepCount(row.Payload) {
+ return false, nil
+ }
+ if _, err := s.DB().Exec(
+ `UPDATE learn_candidates SET payload = ? WHERE id = ? AND status = ?`,
+ payload, row.ID, store.CandidateStatusOpen,
+ ); err != nil {
+ return false, fmt.Errorf("synthesis: refresh payload: %w", err)
+ }
+ return true, nil
+}
+
+// SynthesizePlaybookCandidate derives a quarantined playbook_candidate
+// from the current session's recall-to-teach journal episode. The
+// teach command calls it after a successful resource teach and after
+// the candidate-promotion hook, so a family whose open candidate the
+// teach just promoted (or that the teach covered via --playbook-file)
+// already has a playbook row and short-circuits here.
+//
+// Returns the candidate row and whether this call recorded anything —
+// an insert OR a sightings bump on the existing open candidate. Every
+// abort condition (capture disabled, family already covered by a
+// playbook, no trustworthy episode, empty step list, rejected-signature
+// tombstone) returns (zero row, false, nil): aborting synthesis is a
+// non-event, never an error, and can never fail the teach.
+func SynthesizePlaybookCandidate(s *store.Store, queryFamily, sessionKey string) (store.CandidateRow, bool, error) {
+ queryFamily = strings.TrimSpace(queryFamily)
+ if s == nil || queryFamily == "" || strings.TrimSpace(sessionKey) == "" {
+ return store.CandidateRow{}, false, nil
+ }
+ // Harness silence: verify/dogfood env and the capture switches
+ // leave no journal entries; synthesis must leave no candidate rows.
+ if JournalCaptureDisabled() {
+ return store.CandidateRow{}, false, nil
+ }
+ // Dedup gate: a family already covered by a playbook (seeded,
+ // agent-taught, or just materialized by candidate promotion) has
+ // nothing to synthesize.
+ if _, exists, err := s.GetPlaybookByFamily(queryFamily); err != nil {
+ return store.CandidateRow{}, false, fmt.Errorf("synthesis: playbook lookup: %w", err)
+ } else if exists {
+ return store.CandidateRow{}, false, nil
+ }
+
+ entries, _, err := ReadJournalFrom(JournalOffset{})
+ if err != nil {
+ return store.CandidateRow{}, false, fmt.Errorf("synthesis: read journal: %w", err)
+ }
+ ep, ok := extractSynthesisEpisode(entries, queryFamily, sessionKey)
+ if !ok || len(ep.steps) == 0 {
+ return store.CandidateRow{}, false, nil
+ }
+
+ playbookJSON, err := MarshalPlaybook(Playbook{
+ Steps: ep.steps,
+ ExpectedToolCalls: len(ep.steps),
+ })
+ if err != nil {
+ return store.CandidateRow{}, false, fmt.Errorf("synthesis: %w", err)
+ }
+ payloadBytes, err := json.Marshal(synthesisCandidatePayload{
+ QueryFamily: queryFamily,
+ PlaybookJSON: playbookJSON,
+ NotesText: strings.Join(ep.notes, "\n"),
+ })
+ if err != nil {
+ return store.CandidateRow{}, false, fmt.Errorf("synthesis: marshal payload: %w", err)
+ }
+ payload := string(payloadBytes)
+
+ row, inserted, err := s.DeriveCandidate(
+ store.CandidateClassPlaybookCandidate,
+ payload,
+ synthesisSignature(queryFamily),
+ queryFamily,
+ ep.commandPath,
+ )
+ if err != nil {
+ return store.CandidateRow{}, false, fmt.Errorf("synthesis: derive candidate: %w", err)
+ }
+ if row.Status == store.CandidateStatusRejected {
+ // Tombstoned signature: DeriveCandidate dropped the observation
+ // and future episodes for this family stay dropped until purge.
+ return store.CandidateRow{}, false, nil
+ }
+ if !inserted {
+ // Existing open candidate: the sighting is already counted;
+ // refresh the payload only when the fresh episode is shorter.
+ refreshed, err := refreshShorterSynthesis(s, row, payload, len(ep.steps))
+ if err != nil {
+ return store.CandidateRow{}, false, err
+ }
+ if refreshed {
+ row.Payload = payload
+ }
+ }
+ return row, true, nil
+}
diff --git a/library/productivity/human-goat/internal/learn/synthesize_test.go b/library/productivity/human-goat/internal/learn/synthesize_test.go
new file mode 100644
index 0000000000..685e201556
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/synthesize_test.go
@@ -0,0 +1,350 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Teach-time playbook synthesis scenarios. Lives in the external
+// learn_test package alongside the journal scenarios so it can reuse
+// their state-dir isolation helpers (withJournalHome, writeRawSegment)
+// and drive the synthesis entry point against a real SQLite store plus
+// real journal segment files.
+//
+// Journal fixtures are written as raw segment lines (not through
+// AppendJournalEntry) so scenarios control every field — including the
+// deliberately-absent session keys that AppendJournalEntry would
+// otherwise fill in.
+
+package learn_test
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+// synthesisFamily is the query family every scenario teaches under.
+const synthesisFamily = "status widget"
+
+// openSynthesisStore opens a fresh store in a per-test temp dir. The
+// full v-current schema (learning_playbooks, learn_candidates) comes
+// from the store's own migration path — real SQLite, no fixtures.
+func openSynthesisStore(t *testing.T) *store.Store {
+ t.Helper()
+ s, err := store.Open(filepath.Join(t.TempDir(), "data.db"))
+ if err != nil {
+ t.Fatalf("store.Open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+// synthesisLines marshals entries into raw journal JSONL lines.
+func synthesisLines(t *testing.T, entries ...learn.JournalEntry) []string {
+ t.Helper()
+ lines := make([]string, 0, len(entries))
+ for _, e := range entries {
+ if e.TS == "" {
+ e.TS = "2026-01-02T03:04:05Z"
+ }
+ b, err := json.Marshal(e)
+ if err != nil {
+ t.Fatalf("marshal fixture entry: %v", err)
+ }
+ lines = append(lines, string(b))
+ }
+ return lines
+}
+
+// synthesisHappyEntries is the canonical episode: recall miss for the
+// family, one clean discovery command, one failed-flag attempt with
+// the journaled suggestion, its corrected retry, then learn-family and
+// framework commands that must never become steps.
+func synthesisHappyEntries(session string) []learn.JournalEntry {
+ return []learn.JournalEntry{
+ {SessionKey: session, Cmd: []string{"recall"}, ExitCode: 0, QueryFamily: synthesisFamily},
+ {SessionKey: session, Cmd: []string{"items", "list"}, ArgvShape: map[string]string{"limit": "int"}, ExitCode: 0},
+ {SessionKey: session, Cmd: []string{"items", "get"}, ExitCode: 2, ErrorClass: "usage", FailedFlag: "date", SuggestedFlag: "dates"},
+ {SessionKey: session, Cmd: []string{"items", "get"}, ArgvShape: map[string]string{"dates": "str"}, ExitCode: 0},
+ {SessionKey: session, Cmd: []string{"learnings", "list"}, ExitCode: 0},
+ {SessionKey: session, Cmd: []string{"version"}, ExitCode: 0},
+ }
+}
+
+// decodeSynthesisPayload unwraps a candidate payload down to the
+// stored Playbook + notes.
+func decodeSynthesisPayload(t *testing.T, payload string) (learn.Playbook, string) {
+ t.Helper()
+ var p struct {
+ QueryFamily string `json:"query_family"`
+ PlaybookJSON string `json:"playbook_json"`
+ NotesText string `json:"notes_text"`
+ }
+ if err := json.Unmarshal([]byte(payload), &p); err != nil {
+ t.Fatalf("payload not valid JSON: %v\npayload: %s", err, payload)
+ }
+ if p.QueryFamily != synthesisFamily {
+ t.Errorf("payload query_family = %q, want %q", p.QueryFamily, synthesisFamily)
+ }
+ pb, err := learn.ParsePlaybook([]byte(p.PlaybookJSON), "candidate payload")
+ if err != nil {
+ t.Fatalf("payload playbook_json does not parse: %v", err)
+ }
+ return pb, p.NotesText
+}
+
+func TestSynthesis_HappyPathCreatesQuarantinedCandidate(t *testing.T) {
+ withJournalHome(t)
+ s := openSynthesisStore(t)
+ writeRawSegment(t, "journal-20260102.jsonl", synthesisLines(t, synthesisHappyEntries("sess-a")...)...)
+
+ row, recorded, err := learn.SynthesizePlaybookCandidate(s, synthesisFamily, "sess-a")
+ if err != nil {
+ t.Fatalf("SynthesizePlaybookCandidate: %v", err)
+ }
+ if !recorded {
+ t.Fatalf("recorded = false, want a synthesized candidate")
+ }
+ if row.Class != store.CandidateClassPlaybookCandidate {
+ t.Errorf("class = %q, want playbook_candidate", row.Class)
+ }
+ if row.Status != store.CandidateStatusOpen {
+ t.Errorf("status = %q, want open", row.Status)
+ }
+ if row.Sightings != 1 {
+ t.Errorf("sightings = %d, want 1", row.Sightings)
+ }
+ if row.QueryFamily != synthesisFamily {
+ t.Errorf("query_family = %q, want %q", row.QueryFamily, synthesisFamily)
+ }
+
+ pb, notes := decodeSynthesisPayload(t, row.Payload)
+ wantSteps := []string{
+ "items list --limit ",
+ "items get --dates ",
+ }
+ if len(pb.Steps) != len(wantSteps) {
+ t.Fatalf("steps = %d, want %d\nplaybook: %+v", len(pb.Steps), len(wantSteps), pb)
+ }
+ for i, want := range wantSteps {
+ if pb.Steps[i].Cmd != want {
+ t.Errorf("step[%d] = %q, want %q", i, pb.Steps[i].Cmd, want)
+ }
+ }
+ // No learn-family or framework command may appear as a step.
+ for i, step := range pb.Steps {
+ for _, banned := range []string{"recall", "learnings", "teach", "playbook", "version"} {
+ if strings.HasPrefix(step.Cmd, banned+" ") || step.Cmd == banned {
+ t.Errorf("step[%d] = %q leaks excluded command %q", i, step.Cmd, banned)
+ }
+ }
+ }
+ if pb.ExpectedToolCalls != len(pb.Steps) {
+ t.Errorf("expected_tool_calls = %d, want step count %d", pb.ExpectedToolCalls, len(pb.Steps))
+ }
+ // The journaled failed/suggested flag pair surfaces as a note.
+ if !strings.Contains(notes, "--date") || !strings.Contains(notes, "--dates") {
+ t.Errorf("notes = %q, want the --date -> --dates correction", notes)
+ }
+
+ // Quarantine is structural: synthesis leaves NO learning_playbooks
+ // row — only `learnings confirm` materializes.
+ if _, exists, err := s.GetPlaybookByFamily(synthesisFamily); err != nil {
+ t.Fatalf("GetPlaybookByFamily: %v", err)
+ } else if exists {
+ t.Errorf("learning_playbooks has a row for %q post-synthesis; candidates must stay quarantined", synthesisFamily)
+ }
+}
+
+func TestSynthesis_AbortConditions(t *testing.T) {
+ step := func(session string) learn.JournalEntry {
+ return learn.JournalEntry{SessionKey: session, Cmd: []string{"items", "list"}, ExitCode: 0}
+ }
+ recallFor := func(session, family string) learn.JournalEntry {
+ return learn.JournalEntry{SessionKey: session, Cmd: []string{"recall"}, ExitCode: 0, QueryFamily: family}
+ }
+
+ cases := []struct {
+ name string
+ session string
+ entries []learn.JournalEntry
+ }{
+ {
+ name: "foreign recall interleave",
+ session: "sess-a",
+ entries: []learn.JournalEntry{
+ recallFor("sess-a", synthesisFamily),
+ step("sess-a"),
+ recallFor("sess-a", "other family"),
+ step("sess-a"),
+ },
+ },
+ {
+ name: "no recall for the family in the session",
+ session: "sess-a",
+ entries: []learn.JournalEntry{
+ recallFor("sess-b", synthesisFamily),
+ step("sess-a"),
+ step("sess-a"),
+ },
+ },
+ {
+ name: "missing session key on the teach",
+ session: "",
+ entries: synthesisHappyEntries("sess-a"),
+ },
+ {
+ name: "window entry without a session key",
+ session: "sess-a",
+ entries: []learn.JournalEntry{
+ recallFor("sess-a", synthesisFamily),
+ step(""),
+ step("sess-a"),
+ },
+ },
+ {
+ name: "no successful steps in the window",
+ session: "sess-a",
+ entries: []learn.JournalEntry{
+ recallFor("sess-a", synthesisFamily),
+ {SessionKey: "sess-a", Cmd: []string{"items", "get"}, ExitCode: 2, ErrorClass: "usage"},
+ },
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ withJournalHome(t)
+ s := openSynthesisStore(t)
+ writeRawSegment(t, "journal-20260102.jsonl", synthesisLines(t, tc.entries...)...)
+
+ _, recorded, err := learn.SynthesizePlaybookCandidate(s, synthesisFamily, tc.session)
+ if err != nil {
+ t.Fatalf("SynthesizePlaybookCandidate: %v", err)
+ }
+ if recorded {
+ t.Errorf("recorded = true, want abort (nothing synthesized)")
+ }
+ rows, err := s.ListCandidates(store.ListCandidatesFilter{})
+ if err != nil {
+ t.Fatalf("ListCandidates: %v", err)
+ }
+ if len(rows) != 0 {
+ t.Errorf("learn_candidates has %d row(s), want 0: %+v", len(rows), rows)
+ }
+ })
+ }
+}
+
+func TestSynthesis_ExistingPlaybookSkips(t *testing.T) {
+ withJournalHome(t)
+ s := openSynthesisStore(t)
+ if _, _, err := s.UpsertPlaybook(store.UpsertPlaybookInput{
+ QueryFamily: synthesisFamily,
+ NotesText: "already covered",
+ }); err != nil {
+ t.Fatalf("UpsertPlaybook: %v", err)
+ }
+ writeRawSegment(t, "journal-20260102.jsonl", synthesisLines(t, synthesisHappyEntries("sess-a")...)...)
+
+ _, recorded, err := learn.SynthesizePlaybookCandidate(s, synthesisFamily, "sess-a")
+ if err != nil {
+ t.Fatalf("SynthesizePlaybookCandidate: %v", err)
+ }
+ if recorded {
+ t.Errorf("recorded = true, want skip when the family already has a playbook")
+ }
+ rows, err := s.ListCandidates(store.ListCandidatesFilter{})
+ if err != nil {
+ t.Fatalf("ListCandidates: %v", err)
+ }
+ if len(rows) != 0 {
+ t.Errorf("learn_candidates has %d row(s), want 0", len(rows))
+ }
+}
+
+// TestSynthesis_OpenCandidateBumpsAndShorterEpisodeRefreshes drives
+// three episodes for one family: the first inserts (2 steps), a longer
+// second episode (3 steps) bumps sightings but must NOT replace the
+// payload, and a shorter third episode (1 step) bumps again AND
+// refreshes the payload.
+func TestSynthesis_OpenCandidateBumpsAndShorterEpisodeRefreshes(t *testing.T) {
+ withJournalHome(t)
+ s := openSynthesisStore(t)
+
+ recallEntry := learn.JournalEntry{SessionKey: "sess-a", Cmd: []string{"recall"}, ExitCode: 0, QueryFamily: synthesisFamily}
+ stepEntry := func(sub string) learn.JournalEntry {
+ return learn.JournalEntry{SessionKey: "sess-a", Cmd: []string{"items", sub}, ExitCode: 0}
+ }
+
+ // Episode 1: two steps -> insert at sightings 1.
+ writeRawSegment(t, "journal-20260102.jsonl", synthesisLines(t,
+ recallEntry, stepEntry("list"), stepEntry("get"),
+ )...)
+ row1, recorded, err := learn.SynthesizePlaybookCandidate(s, synthesisFamily, "sess-a")
+ if err != nil || !recorded {
+ t.Fatalf("episode 1: recorded=%v err=%v", recorded, err)
+ }
+ if row1.Sightings != 1 {
+ t.Fatalf("episode 1 sightings = %d, want 1", row1.Sightings)
+ }
+
+ // Episode 2: three steps (a fresh recall anchors a new window) ->
+ // sightings bump, payload keeps the 2-step choreography.
+ writeRawSegment(t, "journal-20260103.jsonl", synthesisLines(t,
+ recallEntry, stepEntry("list"), stepEntry("get"), stepEntry("describe"),
+ )...)
+ row2, recorded, err := learn.SynthesizePlaybookCandidate(s, synthesisFamily, "sess-a")
+ if err != nil || !recorded {
+ t.Fatalf("episode 2: recorded=%v err=%v", recorded, err)
+ }
+ if row2.Sightings != 2 {
+ t.Errorf("episode 2 sightings = %d, want 2", row2.Sightings)
+ }
+ pb2, _ := decodeSynthesisPayload(t, row2.Payload)
+ if len(pb2.Steps) != 2 {
+ t.Errorf("episode 2 payload steps = %d, want the original 2 (longer episode must not refresh)", len(pb2.Steps))
+ }
+
+ // Episode 3: one step -> sightings bump AND payload refresh.
+ writeRawSegment(t, "journal-20260104.jsonl", synthesisLines(t,
+ recallEntry, stepEntry("summary"),
+ )...)
+ row3, recorded, err := learn.SynthesizePlaybookCandidate(s, synthesisFamily, "sess-a")
+ if err != nil || !recorded {
+ t.Fatalf("episode 3: recorded=%v err=%v", recorded, err)
+ }
+ if row3.Sightings != 3 {
+ t.Errorf("episode 3 sightings = %d, want 3", row3.Sightings)
+ }
+ pb3, _ := decodeSynthesisPayload(t, row3.Payload)
+ if len(pb3.Steps) != 1 {
+ t.Fatalf("episode 3 payload steps = %d, want refreshed single step", len(pb3.Steps))
+ }
+ if pb3.Steps[0].Cmd != "items summary" {
+ t.Errorf("refreshed step = %q, want %q", pb3.Steps[0].Cmd, "items summary")
+ }
+ if pb3.ExpectedToolCalls != 1 {
+ t.Errorf("refreshed expected_tool_calls = %d, want 1", pb3.ExpectedToolCalls)
+ }
+
+ // Dedup holds: exactly one open candidate row for the family.
+ rows, err := s.ListCandidates(store.ListCandidatesFilter{
+ Status: store.CandidateStatusOpen,
+ Class: store.CandidateClassPlaybookCandidate,
+ QueryFamily: synthesisFamily,
+ })
+ if err != nil {
+ t.Fatalf("ListCandidates: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Errorf("open candidates = %d, want exactly 1", len(rows))
+ }
+
+ // Quarantine held across all three episodes.
+ if _, exists, err := s.GetPlaybookByFamily(synthesisFamily); err != nil {
+ t.Fatalf("GetPlaybookByFamily: %v", err)
+ } else if exists {
+ t.Errorf("learning_playbooks gained a row for %q without a confirm", synthesisFamily)
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/teach.go b/library/productivity/human-goat/internal/learn/teach.go
new file mode 100644
index 0000000000..1cb1935704
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/teach.go
@@ -0,0 +1,109 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// teach.go implements teach-time resource-shape validation: a soft
+// guardrail that emits warnings when the LLM teaches a resource that
+// has no entity overlap with the query (the "wrong team teach" anti-
+// pattern).
+//
+// The validator NEVER blocks a teach. Warnings land in teach.log for
+// offline review; learnings list --warnings surfaces them. Calls are
+// best-effort -- a DB-side failure mid-validation is swallowed and the
+// teach still returns success, because the teach call itself has
+// already succeeded by the time the validator runs.
+
+package learn
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn/entities"
+)
+
+// Warning is one teach-time validation finding. The shape is the JSON
+// envelope written to teach.log and consumed by `learnings list
+// --warnings`. Adding a new field is backward-compatible for the LLM
+// consumer; renaming an existing JSON tag is not.
+type Warning struct {
+ // Code is the stable warning identifier the LLM keys on
+ // (e.g., "no_entity_overlap"). Matches the warning constants in
+ // match.go so the same vocabulary is used at teach time and
+ // recall time.
+ Code string `json:"code"`
+
+ // Resource is the resource_id the teach call recorded -- the
+ // thing the warning is ABOUT.
+ Resource string `json:"resource"`
+
+ // Detail is a human-readable explanation of why the warning
+ // fired. Stable enough to grep for, but not parsed by callers.
+ Detail string `json:"detail"`
+
+ // Suggested optionally names a strictly-better target the LLM
+ // could have taught against instead. Empty when the warning
+ // describes a quality issue with no concrete substitute.
+ Suggested string `json:"suggested,omitempty"`
+}
+
+// ValidateResourceShape inspects the resource being taught against
+// the query's entities, returning a slice of warnings. It NEVER
+// returns an error: the teach itself has already succeeded and a
+// validator failure shouldn't propagate.
+//
+// Rule: no_entity_overlap -- query has entities AND the resource
+// (via ResourceEntitiesFromJSON) has entities AND no overlap.
+// Catches "LLM taught the wrong target" cases. Soft warning: a
+// categorical-pattern teach with no entities on the query side is
+// fine, and a resource we can't read entities from is silently
+// skipped (validation impossible, not failed).
+//
+// Domain-specific rules (e.g., "parent event when child exists" for
+// Kalshi events) belong in per-CLI code, not in the generator
+// template — the generator can't know what "parent vs child" means
+// for an arbitrary API. Per-CLI teach hooks can call this function
+// then layer their own validators on top.
+func ValidateResourceShape(ctx context.Context, db *sql.DB, cfg *entities.Config, query, resourceID, resourceType string, resourceFields []string) []Warning {
+ if db == nil || resourceID == "" {
+ return nil
+ }
+ parsed := entities.Extract(query, cfg)
+ queryEntities := parsed.Entities
+ if len(queryEntities) == 0 {
+ return nil
+ }
+ resourceEntities := lookupResourceEntities(ctx, db, cfg, resourceType, resourceID, resourceFields)
+ if len(resourceEntities) == 0 {
+ // Can't validate what we can't read; silence is the right
+ // default.
+ return nil
+ }
+ match := ClassifyEntityMatch(queryEntities, resourceEntities)
+ if match != EntityMatchMismatch {
+ return nil
+ }
+ return []Warning{{
+ Code: "no_entity_overlap",
+ Resource: resourceID,
+ Detail: fmt.Sprintf(
+ "query entities %v do not overlap with resource entities %v; verify this is the right resource for the query",
+ queryEntities, resourceEntities,
+ ),
+ }}
+}
+
+func lookupResourceEntities(ctx context.Context, db *sql.DB, cfg *entities.Config, resourceType, resourceID string, fields []string) []string {
+ if resourceID == "" {
+ return nil
+ }
+ var data string
+ err := db.QueryRowContext(ctx,
+ `SELECT data FROM resources WHERE resource_type = ? AND id = ?`,
+ resourceType, resourceID,
+ ).Scan(&data)
+ if err != nil {
+ return nil
+ }
+ return ResourceEntitiesFromJSON([]byte(data), fields, cfg)
+}
diff --git a/library/productivity/human-goat/internal/learn/teach_log.go b/library/productivity/human-goat/internal/learn/teach_log.go
new file mode 100644
index 0000000000..a27183718b
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/teach_log.go
@@ -0,0 +1,159 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+)
+
+// teachLogStateDirName is the per-user state directory name.
+const teachLogStateDirName = "human-goat-pp-cli"
+
+// teachLogFileName is the on-disk log name. Shared with any plain-text
+// error log written by the CLI side — the JSONL reader skips non-JSON
+// lines so the two formats can coexist.
+const teachLogFileName = "teach.log"
+
+// TeachLogEntry is the on-disk record shape for one structured teach-
+// log warning. Round-trippable: writers marshal, readers unmarshal.
+// Added fields must keep `omitempty` so older readers parsing a
+// newer-shape entry still see a valid record.
+type TeachLogEntry struct {
+ TS string `json:"ts"`
+ Action string `json:"action"`
+ Query string `json:"query,omitempty"`
+ Resource string `json:"resource,omitempty"`
+ Warning string `json:"warning"`
+ Detail string `json:"detail,omitempty"`
+ Suggested string `json:"suggested,omitempty"`
+}
+
+// TeachLogPath returns the on-disk path to the structured teach log.
+// Creates the parent directory on first call (mode 0o700).
+func TeachLogPath() (string, error) {
+ dir, err := cliutil.StateDir()
+ if err != nil {
+ return "", fmt.Errorf("teach log: resolve state dir: %w", err)
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("teach log: mkdir %s: %w", dir, err)
+ }
+ return filepath.Join(dir, teachLogFileName), nil
+}
+
+func legacyTeachLogPath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("teach log: resolve home: %w", err)
+ }
+ dir := filepath.Join(home, ".local", "share", teachLogStateDirName)
+ return filepath.Join(dir, teachLogFileName), nil
+}
+
+// AppendTeachLogWarning writes one structured warning to the teach
+// log. Best-effort: returns an error so callers that want to know
+// can act on it, but the canonical teach-time hook ignores the error
+// (the teach itself has already succeeded by the time this runs).
+func AppendTeachLogWarning(action, query string, w Warning) error {
+ if w.Code == "" {
+ return fmt.Errorf("teach log: warning code is required")
+ }
+ entry := TeachLogEntry{
+ TS: time.Now().UTC().Format(time.RFC3339),
+ Action: action,
+ Query: query,
+ Resource: w.Resource,
+ Warning: w.Code,
+ Detail: w.Detail,
+ Suggested: w.Suggested,
+ }
+ return appendTeachLogEntry(entry)
+}
+
+func appendTeachLogEntry(entry TeachLogEntry) error {
+ p, err := TeachLogPath()
+ if err != nil {
+ return err
+ }
+ line, err := json.Marshal(entry)
+ if err != nil {
+ return fmt.Errorf("teach log: marshal: %w", err)
+ }
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("teach log: open %s: %w", p, err)
+ }
+ defer f.Close()
+ if _, err := f.Write(append(line, '\n')); err != nil {
+ return fmt.Errorf("teach log: write: %w", err)
+ }
+ return nil
+}
+
+// ReadTeachLogWarnings returns every structured warning recorded in
+// the teach log, optionally filtered by resource_id. Non-JSON lines
+// are silently skipped. Missing file returns (nil, nil).
+func ReadTeachLogWarnings(resourceIDs ...string) ([]TeachLogEntry, error) {
+ p, err := TeachLogPath()
+ if err != nil {
+ return nil, err
+ }
+ legacy, legacyErr := legacyTeachLogPath()
+ if legacyErr != nil || legacy == p {
+ legacy = ""
+ }
+ data, sourcePath, err := cliutil.ReadFileWithLegacyFallback(p, legacy)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if sourcePath == legacy {
+ return nil, fmt.Errorf("teach log: open %s: %w", legacy, err)
+ }
+ return nil, fmt.Errorf("teach log: open %s: %w", p, err)
+ }
+
+ filter := make(map[string]struct{}, len(resourceIDs))
+ for _, id := range resourceIDs {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ continue
+ }
+ filter[id] = struct{}{}
+ }
+ wantFilter := len(filter) > 0
+
+ var out []TeachLogEntry
+ scanner := bufio.NewScanner(bytes.NewReader(data))
+ scanner.Buffer(make([]byte, 0, 4096), 1<<20)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 || line[0] != '{' {
+ continue
+ }
+ var entry TeachLogEntry
+ if err := json.Unmarshal(line, &entry); err != nil {
+ continue
+ }
+ if wantFilter {
+ if _, ok := filter[entry.Resource]; !ok {
+ continue
+ }
+ }
+ out = append(out, entry)
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("teach log: scan: %w", err)
+ }
+ return out, nil
+}
diff --git a/library/productivity/human-goat/internal/learn/teach_log_test.go b/library/productivity/human-goat/internal/learn/teach_log_test.go
new file mode 100644
index 0000000000..8ad65c8dea
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/teach_log_test.go
@@ -0,0 +1,205 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+)
+
+func withTempHomeForLog(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ t.Setenv("HOME", dir)
+ t.Setenv("HUMAN_GOAT_STATE_DIR", "")
+ t.Setenv("HUMAN_GOAT_HOME", "")
+ t.Setenv("XDG_STATE_HOME", "")
+ return dir
+}
+
+func teachLogPathForTest(t *testing.T) string {
+ t.Helper()
+ dir, err := cliutil.StateDir()
+ if err != nil {
+ t.Fatalf("StateDir() error = %v", err)
+ }
+ return filepath.Join(dir, teachLogFileName)
+}
+
+func readRawTeachLog(t *testing.T) string {
+ t.Helper()
+ p := teachLogPathForTest(t)
+ b, err := os.ReadFile(p) // #nosec G304 -- temp test path
+ if err != nil {
+ t.Fatalf("read teach.log: %v", err)
+ }
+ return string(b)
+}
+
+func TestAppendTeachLogWarning_CreatesDirectoryAndFile(t *testing.T) {
+ withTempHomeForLog(t)
+ stateDir := filepath.Dir(teachLogPathForTest(t))
+ if _, err := os.Stat(stateDir); !os.IsNotExist(err) {
+ t.Fatalf("precondition: state dir should not exist yet")
+ }
+ w := Warning{
+ Code: "no_entity_overlap",
+ Resource: "PREFIX-X",
+ Detail: "test",
+ }
+ if err := AppendTeachLogWarning("teach", "alpha widget", w); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ if info, err := os.Stat(stateDir); err != nil {
+ t.Fatalf("state dir should exist: %v", err)
+ } else if !info.IsDir() {
+ t.Fatalf("state dir not a directory")
+ }
+ raw := readRawTeachLog(t)
+ if !strings.HasSuffix(raw, "\n") {
+ t.Errorf("line should end with newline; got %q", raw)
+ }
+ if !strings.HasPrefix(strings.TrimSpace(raw), "{") {
+ t.Errorf("structured entry should start with '{'; got %q", raw)
+ }
+}
+
+func TestAppendTeachLogWarning_JSONShape(t *testing.T) {
+ withTempHomeForLog(t)
+ w := Warning{
+ Code: "no_entity_overlap",
+ Resource: "PREFIX-X",
+ Detail: "test",
+ Suggested: "PREFIX-Y",
+ }
+ if err := AppendTeachLogWarning("teach", "alpha widget", w); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ raw := readRawTeachLog(t)
+ line := strings.TrimSpace(raw)
+ var entry TeachLogEntry
+ if err := json.Unmarshal([]byte(line), &entry); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if entry.Action != "teach" {
+ t.Errorf("action = %q", entry.Action)
+ }
+ if entry.Query != "alpha widget" {
+ t.Errorf("query = %q", entry.Query)
+ }
+ if entry.Suggested != "PREFIX-Y" {
+ t.Errorf("suggested = %q", entry.Suggested)
+ }
+ if entry.TS == "" {
+ t.Errorf("ts should be populated")
+ }
+}
+
+func TestAppendTeachLogWarning_MultipleAppendsPreserveOrder(t *testing.T) {
+ withTempHomeForLog(t)
+ for i, code := range []string{"first_warning", "second_warning"} {
+ w := Warning{
+ Code: code,
+ Resource: "RESOURCE-" + string(rune('A'+i)),
+ }
+ if err := AppendTeachLogWarning("teach", "q", w); err != nil {
+ t.Fatalf("append %d: %v", i, err)
+ }
+ }
+ entries, err := ReadTeachLogWarnings()
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if len(entries) != 2 {
+ t.Fatalf("want 2 entries, got %d", len(entries))
+ }
+ if entries[0].Resource != "RESOURCE-A" || entries[1].Resource != "RESOURCE-B" {
+ t.Errorf("order broken; got %+v", entries)
+ }
+}
+
+func TestAppendTeachLogWarning_EmptyCodeIsAnError(t *testing.T) {
+ withTempHomeForLog(t)
+ if err := AppendTeachLogWarning("teach", "q", Warning{}); err == nil {
+ t.Errorf("want error for empty code")
+ }
+}
+
+func TestReadTeachLogWarnings_MissingFileReturnsEmpty(t *testing.T) {
+ withTempHomeForLog(t)
+ entries, err := ReadTeachLogWarnings()
+ if err != nil {
+ t.Fatalf("want nil error, got %v", err)
+ }
+ if entries != nil {
+ t.Errorf("want nil entries, got %+v", entries)
+ }
+}
+
+func TestReadTeachLogWarnings_FilterByResourceID(t *testing.T) {
+ withTempHomeForLog(t)
+ for _, rid := range []string{"A", "B", "C"} {
+ if err := AppendTeachLogWarning("teach", "q", Warning{Code: "code", Resource: rid}); err != nil {
+ t.Fatalf("append %s: %v", rid, err)
+ }
+ }
+ got, err := ReadTeachLogWarnings("B", "C")
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("want 2 entries, got %d", len(got))
+ }
+}
+
+func TestReadTeachLogWarnings_SkipsNonJSONLines(t *testing.T) {
+ withTempHomeForLog(t)
+ if err := AppendTeachLogWarning("teach", "q", Warning{Code: "code", Resource: "STRUCTURED"}); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ p := teachLogPathForTest(t)
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o600)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ if _, err := f.WriteString("2026-05-23T20:01:23Z teach: legacy plain text line\n"); err != nil {
+ f.Close()
+ t.Fatalf("write legacy: %v", err)
+ }
+ f.Close()
+ got, err := ReadTeachLogWarnings()
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if len(got) != 1 || got[0].Resource != "STRUCTURED" {
+ t.Errorf("want structured-only entry; got %+v", got)
+ }
+}
+
+func TestAppendTeachLogWarning_ConcurrentAppends(t *testing.T) {
+ withTempHomeForLog(t)
+ const n = 16
+ var wg sync.WaitGroup
+ wg.Add(n)
+ for i := 0; i < n; i++ {
+ go func(i int) {
+ defer wg.Done()
+ _ = AppendTeachLogWarning("teach", "q", Warning{Code: "code", Resource: string(rune('A' + (i % 26)))})
+ }(i)
+ }
+ wg.Wait()
+ got, err := ReadTeachLogWarnings()
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if len(got) != n {
+ t.Errorf("want %d entries, got %d", n, len(got))
+ }
+}
diff --git a/library/productivity/human-goat/internal/learn/teach_test.go b/library/productivity/human-goat/internal/learn/teach_test.go
new file mode 100644
index 0000000000..b0dc167bea
--- /dev/null
+++ b/library/productivity/human-goat/internal/learn/teach_test.go
@@ -0,0 +1,84 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package learn
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+// Teach writes go through store.UpsertLearning; learn.Teach was removed
+// in the canonical-restore pass. Tests for the write path live next to
+// the store package.
+
+func TestValidateResourceShape_MismatchEmitsWarning(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-BR", `{"title":"Bravo widget"}`)
+ got := ValidateResourceShape(
+ context.Background(), db, testConfig(),
+ "Alpha widget", // query carrying entity "Alpha" (capitalized)
+ "PREFIX-BR", // taught Bravo
+ "widgets",
+ []string{"title"},
+ )
+ if len(got) != 1 {
+ t.Fatalf("want 1 warning, got %d: %+v", len(got), got)
+ }
+ if got[0].Code != "no_entity_overlap" {
+ t.Errorf("Code = %q, want no_entity_overlap", got[0].Code)
+ }
+ if !strings.Contains(got[0].Detail, "Alpha") || !strings.Contains(got[0].Detail, "Bravo") {
+ t.Errorf("detail should mention both sides; got %q", got[0].Detail)
+ }
+}
+
+func TestValidateResourceShape_QueryWithoutEntitiesNoWarning(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ seedRecallResource(t, db, "widgets", "PREFIX-BR", `{"title":"Bravo widget"}`)
+ got := ValidateResourceShape(
+ context.Background(), db, testConfig(),
+ "some lowercase content",
+ "PREFIX-BR",
+ "widgets",
+ []string{"title"},
+ )
+ if got != nil {
+ t.Errorf("query without entities: want nil, got %+v", got)
+ }
+}
+
+func TestValidateResourceShape_ResourceMissingNoWarning(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ got := ValidateResourceShape(
+ context.Background(), db, testConfig(),
+ "Alpha widget",
+ "PREFIX-MISSING",
+ "widgets",
+ []string{"title"},
+ )
+ if got != nil {
+ t.Errorf("missing resource: want nil, got %+v", got)
+ }
+}
+
+func TestValidateResourceShape_NilDBReturnsNil(t *testing.T) {
+ t.Parallel()
+ got := ValidateResourceShape(context.Background(), nil, testConfig(), "alpha", "X", "widgets", nil)
+ if got != nil {
+ t.Errorf("nil db: want nil, got %+v", got)
+ }
+}
+
+func TestValidateResourceShape_EmptyResourceIDReturnsNil(t *testing.T) {
+ t.Parallel()
+ db := openRecallTestDB(t)
+ got := ValidateResourceShape(context.Background(), db, testConfig(), "alpha widget", "", "widgets", nil)
+ if got != nil {
+ t.Errorf("empty resource_id: want nil, got %+v", got)
+ }
+}
diff --git a/library/productivity/human-goat/internal/mcp/bound/bound.go b/library/productivity/human-goat/internal/mcp/bound/bound.go
new file mode 100644
index 0000000000..75cb84a2be
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/bound/bound.go
@@ -0,0 +1,484 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package bound
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+)
+
+const (
+ MaxBytes = 60000
+ MaxItems = 50
+
+ maxPreviewBytes = 4000
+)
+
+const (
+ endpointListNote = "Typed MCP endpoint response was bounded for MCP output. Narrow the request with limit, offset, filters, search/sql, or a command-mirror tool with --agent/--compact/--select."
+ endpointPreviewNote = "Typed MCP endpoint response exceeded the tool result budget and was not a recognized list envelope. Narrow the request with filters, search/sql, or a command-mirror tool with --agent/--compact/--select."
+ jsonResultNote = "MCP JSON result exceeded the tool result budget. Narrow the request with limit, filters, search/sql, or --select/--compact where available."
+ textResultNote = "MCP command output exceeded the tool result budget. Rerun with narrower flags, --agent, --compact, --select, or --limit where available."
+)
+
+// PageOptions describes resumable list context for typed MCP endpoint results.
+// Cursor is the opaque value supplied by the MCP caller. CursorParam is the
+// upstream request parameter name that can resume another API page, when one
+// exists. NextCursorPath is the dotted response path containing the upstream
+// cursor for the following API page.
+type PageOptions struct {
+ Cursor string
+ CursorParam string
+ NextCursorPath string
+}
+
+type endpointCursor struct {
+ Version int `json:"v"`
+ Offset int `json:"o,omitempty"`
+ UpstreamCursor string `json:"u,omitempty"`
+}
+
+// EndpointResponse renders a typed endpoint response within the MCP result
+// budget. GET array responses are always wrapped so agents get count metadata;
+// other small responses pass through unchanged.
+func EndpointResponse(method string, data json.RawMessage) string {
+ return endpointResponse(method, data, PageOptions{})
+}
+
+// EndpointPageResponse renders a typed endpoint response with resumable list
+// cursors. It only changes recognized GET list shapes; opaque or non-list data
+// falls back to EndpointResponse's bounded preview contract.
+func EndpointPageResponse(method string, data json.RawMessage, opts PageOptions) string {
+ if opts.CursorParam == "" && opts.Cursor == "" && opts.NextCursorPath == "" {
+ return endpointResponse(method, data, PageOptions{})
+ }
+ return endpointResponse(method, data, opts)
+}
+
+// UpstreamCursor unwraps the upstream API cursor embedded in an opaque MCP
+// cursor. It returns an empty string for local continuation cursors that resume
+// within the current API page.
+func UpstreamCursor(cursor string) (string, error) {
+ state, err := decodeEndpointCursor(cursor)
+ if err != nil {
+ return "", err
+ }
+ return state.UpstreamCursor, nil
+}
+
+func endpointResponse(method string, data json.RawMessage, opts PageOptions) string {
+ trimmed := strings.TrimSpace(string(data))
+ if strings.EqualFold(method, "GET") && len(trimmed) > 0 && trimmed[0] == '[' {
+ var items []json.RawMessage
+ if json.Unmarshal(data, &items) == nil {
+ if opts.CursorParam != "" {
+ return string(boundedPageListEnvelope("items", items, data, endpointListNote, opts, nil, ""))
+ }
+ return string(boundedListEnvelope("items", items, len(data), endpointListNote))
+ }
+ }
+ if strings.EqualFold(method, "GET") && opts.CursorParam != "" {
+ if out, ok := boundedSingleArrayPageObject(data, opts); ok {
+ return string(out)
+ }
+ }
+ if len(data) <= MaxBytes {
+ return string(data)
+ }
+ if strings.EqualFold(method, "GET") {
+ if out, ok := boundedSingleArrayObject(data); ok {
+ return string(out)
+ }
+ }
+ return previewEnvelope(data, endpointPreviewNote)
+}
+
+// JSON renders an arbitrary JSON value within the MCP result budget. Small
+// results pass through unchanged; oversized arrays and single-array objects
+// become bounded list envelopes before falling back to a preview envelope. The
+// object path preserves metadata fields such as count/status while truncating
+// the large array field.
+func JSON(v any) (string, error) {
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return "", err
+ }
+ if len(data) <= MaxBytes {
+ return string(data), nil
+ }
+
+ compact, err := json.Marshal(v)
+ if err != nil {
+ return "", err
+ }
+ if len(compact) <= MaxBytes {
+ return string(compact), nil
+ }
+
+ trimmed := strings.TrimSpace(string(compact))
+ if len(trimmed) > 0 && trimmed[0] == '[' {
+ var items []json.RawMessage
+ if json.Unmarshal(compact, &items) == nil {
+ return string(boundedListEnvelope("items", items, len(compact), jsonResultNote)), nil
+ }
+ }
+ if len(trimmed) > 0 && trimmed[0] == '{' {
+ if out, ok := boundedSingleArrayObjectWithNote(compact, jsonResultNote); ok {
+ return string(out), nil
+ }
+ }
+ return previewEnvelope(compact, jsonResultNote), nil
+}
+
+// Text renders non-JSON command output within the MCP result budget. Small
+// output passes through unchanged; oversized output becomes a JSON preview
+// envelope so the result stays valid and self-describing.
+func Text(out string) string {
+ if len(out) <= MaxBytes {
+ return out
+ }
+ return previewEnvelope([]byte(out), textResultNote)
+}
+
+func boundedSingleArrayObject(data json.RawMessage) ([]byte, bool) {
+ return boundedSingleArrayObjectWithNote(data, endpointListNote)
+}
+
+func boundedSingleArrayObjectWithNote(data json.RawMessage, note string) ([]byte, bool) {
+ var obj map[string]json.RawMessage
+ if json.Unmarshal(data, &obj) != nil {
+ return nil, false
+ }
+ arrayField := ""
+ var items []json.RawMessage
+ for key, raw := range obj {
+ trimmed := strings.TrimSpace(string(raw))
+ if len(trimmed) == 0 || trimmed[0] != '[' {
+ continue
+ }
+ var candidate []json.RawMessage
+ if json.Unmarshal(raw, &candidate) != nil {
+ continue
+ }
+ if arrayField != "" {
+ return nil, false
+ }
+ arrayField = key
+ items = candidate
+ }
+ if arrayField == "" {
+ return nil, false
+ }
+
+ build := func(subset []json.RawMessage) any {
+ out := make(map[string]any, len(obj)+6)
+ for key, raw := range obj {
+ if key == arrayField {
+ out[key] = subset
+ continue
+ }
+ out[key] = raw
+ }
+ if len(subset) < len(items) {
+ out["_pp_truncated"] = true
+ out["_pp_total_count"] = len(items)
+ out["_pp_returned_count"] = len(subset)
+ out["_pp_original_bytes"] = len(data)
+ out["_pp_max_bytes"] = MaxBytes
+ out["_pp_note"] = note
+ }
+ return out
+ }
+ out := fitJSONItems(items, build)
+ if len(out) > MaxBytes {
+ return nil, false
+ }
+ return out, true
+}
+
+func boundedSingleArrayPageObject(data json.RawMessage, opts PageOptions) ([]byte, bool) {
+ var obj map[string]json.RawMessage
+ if json.Unmarshal(data, &obj) != nil {
+ return nil, false
+ }
+ arrayField := ""
+ var items []json.RawMessage
+ for key, raw := range obj {
+ trimmed := strings.TrimSpace(string(raw))
+ if len(trimmed) == 0 || trimmed[0] != '[' {
+ continue
+ }
+ var candidate []json.RawMessage
+ if json.Unmarshal(raw, &candidate) != nil {
+ continue
+ }
+ if arrayField != "" {
+ return nil, false
+ }
+ arrayField = key
+ items = candidate
+ }
+ if arrayField == "" {
+ return nil, false
+ }
+ nextUpstream := extractStringPath(data, opts.NextCursorPath)
+ return boundedPageListEnvelope(arrayField, items, data, endpointListNote, opts, obj, nextUpstream), true
+}
+
+func boundedListEnvelope(field string, items []json.RawMessage, originalBytes int, note string) []byte {
+ build := func(subset []json.RawMessage) any {
+ out := map[string]any{
+ "count": len(items),
+ field: subset,
+ }
+ if len(subset) < len(items) {
+ out["truncated"] = true
+ out["returned_count"] = len(subset)
+ out["original_bytes"] = originalBytes
+ out["max_bytes"] = MaxBytes
+ out["note"] = note
+ }
+ return out
+ }
+ return fitJSONItems(items, build)
+}
+
+func boundedPageListEnvelope(field string, items []json.RawMessage, original json.RawMessage, note string, opts PageOptions, base map[string]json.RawMessage, nextUpstream string) []byte {
+ state := endpointCursor{Version: 1}
+ if opts.Cursor != "" {
+ if decoded, err := decodeEndpointCursor(opts.Cursor); err == nil {
+ state = decoded
+ }
+ }
+ start := state.Offset
+ if start < 0 {
+ start = 0
+ }
+ if start > len(items) {
+ start = len(items)
+ }
+
+ build := func(subset []json.RawMessage, itemPreview string, nextCursor string) any {
+ var out map[string]any
+ if base == nil {
+ out = map[string]any{}
+ } else {
+ out = make(map[string]any, len(base)+8)
+ for key, raw := range base {
+ if key == field {
+ continue
+ }
+ out[key] = raw
+ }
+ }
+ if nextUpstream != "" || state.UpstreamCursor != "" {
+ out["count"] = -1
+ } else {
+ out["count"] = len(items)
+ }
+ out[field] = subset
+ out["returned_count"] = len(subset)
+ if itemPreview != "" {
+ out["item_preview"] = itemPreview
+ }
+ if nextCursor != "" {
+ out["truncated"] = true
+ out["next_cursor"] = nextCursor
+ out["original_bytes"] = len(original)
+ out["max_bytes"] = MaxBytes
+ out["note"] = note
+ }
+ return out
+ }
+
+ out := fitJSONPageItems(items, start, state.UpstreamCursor, nextUpstream, build)
+ if len(out) > MaxBytes {
+ return []byte(previewEnvelope(original, endpointPreviewNote))
+ }
+ return out
+}
+
+func fitJSONItems(items []json.RawMessage, build func([]json.RawMessage) any) []byte {
+ limit := len(items)
+ if limit > MaxItems {
+ limit = MaxItems
+ }
+ for n := limit; n > 0; n-- {
+ out, err := json.Marshal(build(items[:n]))
+ if err != nil {
+ continue
+ }
+ if len(out) <= MaxBytes {
+ return out
+ }
+ }
+ out, _ := json.Marshal(build(items[:0]))
+ return out
+}
+
+func fitJSONPageItems(items []json.RawMessage, start int, currentUpstream, nextUpstream string, build func([]json.RawMessage, string, string) any) []byte {
+ remaining := len(items) - start
+ if remaining < 0 {
+ remaining = 0
+ }
+ limit := remaining
+ if limit > MaxItems {
+ limit = MaxItems
+ }
+ for n := limit; n > 0; n-- {
+ next := nextPageCursor(start+n, len(items), currentUpstream, nextUpstream)
+ out, err := json.Marshal(build(items[start:start+n], "", next))
+ if err != nil {
+ continue
+ }
+ if len(out) <= MaxBytes {
+ return out
+ }
+ }
+ if start < len(items) {
+ next := nextPageCursor(start+1, len(items), currentUpstream, nextUpstream)
+ previewLimit := maxPreviewBytes
+ for previewLimit >= 0 {
+ out, err := json.Marshal(build(nil, previewString(items[start], previewLimit), next))
+ if err == nil && len(out) <= MaxBytes {
+ return out
+ }
+ if previewLimit == 0 {
+ break
+ }
+ if previewLimit < 512 {
+ previewLimit = 0
+ } else {
+ previewLimit -= 512
+ }
+ }
+ }
+ next := nextPageCursor(len(items), len(items), currentUpstream, nextUpstream)
+ out, _ := json.Marshal(build(nil, "", next))
+ return out
+}
+
+func nextPageCursor(nextOffset, itemCount int, currentUpstream, nextUpstream string) string {
+ state := endpointCursor{Version: 1}
+ switch {
+ case nextOffset < itemCount:
+ state.Offset = nextOffset
+ state.UpstreamCursor = currentUpstream
+ case nextUpstream != "":
+ state.UpstreamCursor = nextUpstream
+ default:
+ return ""
+ }
+ return encodeEndpointCursor(state)
+}
+
+func encodeEndpointCursor(state endpointCursor) string {
+ if state.Version == 0 {
+ state.Version = 1
+ }
+ data, err := json.Marshal(state)
+ if err != nil {
+ return ""
+ }
+ return base64.RawURLEncoding.EncodeToString(data)
+}
+
+func decodeEndpointCursor(cursor string) (endpointCursor, error) {
+ if cursor == "" {
+ return endpointCursor{Version: 1}, nil
+ }
+ data, err := base64.RawURLEncoding.DecodeString(cursor)
+ if err != nil {
+ return endpointCursor{}, fmt.Errorf("invalid MCP cursor")
+ }
+ var state endpointCursor
+ if err := json.Unmarshal(data, &state); err != nil {
+ return endpointCursor{}, fmt.Errorf("invalid MCP cursor")
+ }
+ if state.Version != 1 {
+ return endpointCursor{}, fmt.Errorf("unsupported MCP cursor version")
+ }
+ if state.Offset < 0 {
+ return endpointCursor{}, fmt.Errorf("invalid MCP cursor offset")
+ }
+ return state, nil
+}
+
+func extractStringPath(data json.RawMessage, path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return ""
+ }
+ var v any
+ if json.Unmarshal(data, &v) != nil {
+ return ""
+ }
+ for _, part := range strings.Split(path, ".") {
+ obj, ok := v.(map[string]any)
+ if !ok {
+ return ""
+ }
+ v, ok = obj[part]
+ if !ok {
+ return ""
+ }
+ }
+ if s, ok := v.(string); ok {
+ return s
+ }
+ return ""
+}
+
+func previewEnvelope(data []byte, note string) string {
+ limit := len(data)
+ if limit > maxPreviewBytes {
+ limit = maxPreviewBytes
+ }
+ for limit >= 0 {
+ out, err := json.Marshal(map[string]any{
+ "truncated": true,
+ "resumable": false,
+ "original_bytes": len(data),
+ "max_bytes": MaxBytes,
+ "preview": previewString(data, limit),
+ "note": note,
+ })
+ if err == nil && len(out) <= MaxBytes {
+ return string(out)
+ }
+ if limit == 0 {
+ break
+ }
+ if limit < 512 {
+ limit = 0
+ } else {
+ limit -= 512
+ }
+ }
+ out, _ := json.Marshal(map[string]any{
+ "truncated": true,
+ "resumable": false,
+ "original_bytes": len(data),
+ "max_bytes": MaxBytes,
+ "note": note,
+ })
+ return string(out)
+}
+
+func previewString(data []byte, limit int) string {
+ if limit > len(data) {
+ limit = len(data)
+ }
+ for limit > 0 {
+ r, size := utf8.DecodeLastRune(data[:limit])
+ if r != utf8.RuneError || size != 1 {
+ break
+ }
+ limit--
+ }
+ return string(data[:limit])
+}
diff --git a/library/productivity/human-goat/internal/mcp/bound/bound_test.go b/library/productivity/human-goat/internal/mcp/bound/bound_test.go
new file mode 100644
index 0000000000..3aadf83de1
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/bound/bound_test.go
@@ -0,0 +1,559 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package bound
+
+import (
+ "encoding/json"
+ "strconv"
+ "strings"
+ "testing"
+ "unicode/utf8"
+)
+
+func TestEndpointResponseBoundsListResponses(t *testing.T) {
+ items := make([]string, 0, MaxItems+25)
+ for i := 0; i < MaxItems+25; i++ {
+ items = append(items, strings.Repeat("x", 1600))
+ }
+ data, err := json.Marshal(items)
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := EndpointResponse("GET", data)
+ if len(text) > MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Count int `json:"count"`
+ Items []json.RawMessage `json:"items"`
+ Truncated bool `json:"truncated"`
+ ReturnedCount int `json:"returned_count"`
+ OriginalBytes int `json:"original_bytes"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded list result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded list result did not mark truncation: %s", text)
+ }
+ if envelope.Count != len(items) {
+ t.Fatalf("count = %d, want %d", envelope.Count, len(items))
+ }
+ if envelope.ReturnedCount != len(envelope.Items) {
+ t.Fatalf("returned_count = %d, want item count %d", envelope.ReturnedCount, len(envelope.Items))
+ }
+ if envelope.OriginalBytes != len(data) {
+ t.Fatalf("original_bytes = %d, want %d", envelope.OriginalBytes, len(data))
+ }
+}
+
+func TestEndpointPageResponseEmitsOpaqueCursorForTruncatedList(t *testing.T) {
+ items := make([]map[string]string, 0, MaxItems+25)
+ for i := 0; i < MaxItems+25; i++ {
+ items = append(items, map[string]string{
+ "id": "item-" + strconv.Itoa(i),
+ "payload": strings.Repeat("x", 1600),
+ })
+ }
+ data, err := json.Marshal(items)
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := EndpointPageResponse("GET", data, PageOptions{CursorParam: "cursor"})
+ if len(text) > MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Items []json.RawMessage `json:"items"`
+ Truncated bool `json:"truncated"`
+ ReturnedCount int `json:"returned_count"`
+ NextCursor string `json:"next_cursor"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded list result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded list result did not mark truncation: %s", text)
+ }
+ if envelope.ReturnedCount != len(envelope.Items) {
+ t.Fatalf("returned_count = %d, want item count %d", envelope.ReturnedCount, len(envelope.Items))
+ }
+ if envelope.NextCursor == "" {
+ t.Fatalf("truncated resumable list result did not include next_cursor: %s", text)
+ }
+ if strings.Contains(envelope.NextCursor, "item-") || strings.Contains(envelope.NextCursor, "cursor") {
+ t.Fatalf("next_cursor should be opaque, got %q", envelope.NextCursor)
+ }
+}
+
+func TestEndpointPageResponseContinuesFromOpaqueCursor(t *testing.T) {
+ items := make([]map[string]string, 0, MaxItems+25)
+ for i := 0; i < MaxItems+25; i++ {
+ items = append(items, map[string]string{
+ "id": "item-" + strconv.Itoa(i),
+ "payload": strings.Repeat("x", 1600),
+ })
+ }
+ data, err := json.Marshal(items)
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ firstText := EndpointPageResponse("GET", data, PageOptions{CursorParam: "cursor"})
+ var first struct {
+ Items []map[string]string `json:"items"`
+ NextCursor string `json:"next_cursor"`
+ }
+ if err := json.Unmarshal([]byte(firstText), &first); err != nil {
+ t.Fatalf("first page must remain valid JSON: %v\n%s", err, firstText)
+ }
+ if first.NextCursor == "" {
+ t.Fatalf("first page did not include next_cursor: %s", firstText)
+ }
+
+ secondText := EndpointPageResponse("GET", data, PageOptions{CursorParam: "cursor", Cursor: first.NextCursor})
+ var second struct {
+ Items []map[string]string `json:"items"`
+ ReturnedCount int `json:"returned_count"`
+ }
+ if err := json.Unmarshal([]byte(secondText), &second); err != nil {
+ t.Fatalf("second page must remain valid JSON: %v\n%s", err, secondText)
+ }
+ if len(first.Items) == 0 || len(second.Items) == 0 {
+ t.Fatalf("expected both pages to return items: first=%s second=%s", firstText, secondText)
+ }
+ if got, want := second.Items[0]["id"], items[len(first.Items)]["id"]; got != want {
+ t.Fatalf("second page first id = %q, want %q", got, want)
+ }
+ if second.ReturnedCount != len(second.Items) {
+ t.Fatalf("returned_count = %d, want item count %d", second.ReturnedCount, len(second.Items))
+ }
+}
+
+func TestEndpointPageResponseWrapsUpstreamCursorOpaquely(t *testing.T) {
+ items := []map[string]string{
+ {"id": "item-0", "payload": "small"},
+ {"id": "item-1", "payload": "small"},
+ }
+ data, err := json.Marshal(map[string]any{
+ "items": items,
+ "next_cursor": "server-next-token",
+ })
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := EndpointPageResponse("GET", data, PageOptions{CursorParam: "cursor", NextCursorPath: "next_cursor"})
+ if len(text) > MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Items []map[string]string `json:"items"`
+ ReturnedCount int `json:"returned_count"`
+ NextCursor string `json:"next_cursor"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("page result must remain valid JSON: %v\n%s", err, text)
+ }
+ if envelope.ReturnedCount != len(items) {
+ t.Fatalf("returned_count = %d, want %d", envelope.ReturnedCount, len(items))
+ }
+ if envelope.NextCursor == "" {
+ t.Fatalf("page with upstream cursor did not include next_cursor: %s", text)
+ }
+ if strings.Contains(envelope.NextCursor, "server-next-token") {
+ t.Fatalf("next_cursor leaked upstream cursor: %q", envelope.NextCursor)
+ }
+}
+
+func TestEndpointPageResponseOversizedFirstItemReturnsPreviewAndAdvances(t *testing.T) {
+ items := []map[string]string{
+ {"id": "too-large", "payload": strings.Repeat("x", MaxBytes+10000)},
+ {"id": "next", "payload": "small"},
+ }
+ data, err := json.Marshal(items)
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ firstText := EndpointPageResponse("GET", data, PageOptions{CursorParam: "cursor"})
+ if len(firstText) > MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(firstText), MaxBytes)
+ }
+
+ var first struct {
+ Items []json.RawMessage `json:"items"`
+ ItemPreview string `json:"item_preview"`
+ ReturnedCount int `json:"returned_count"`
+ NextCursor string `json:"next_cursor"`
+ }
+ if err := json.Unmarshal([]byte(firstText), &first); err != nil {
+ t.Fatalf("first page must remain valid JSON: %v\n%s", err, firstText)
+ }
+ if len(first.Items) != 0 || first.ReturnedCount != 0 {
+ t.Fatalf("oversized first item should not claim to return a full item: %s", firstText)
+ }
+ if first.ItemPreview == "" {
+ t.Fatalf("oversized first item should include item_preview: %s", firstText)
+ }
+ if first.NextCursor == "" {
+ t.Fatalf("oversized first item should advance with next_cursor: %s", firstText)
+ }
+
+ secondText := EndpointPageResponse("GET", data, PageOptions{CursorParam: "cursor", Cursor: first.NextCursor})
+ var second struct {
+ Items []map[string]string `json:"items"`
+ }
+ if err := json.Unmarshal([]byte(secondText), &second); err != nil {
+ t.Fatalf("second page must remain valid JSON: %v\n%s", err, secondText)
+ }
+ if len(second.Items) == 0 || second.Items[0]["id"] != "next" {
+ t.Fatalf("second page should advance past oversized item, got %s", secondText)
+ }
+}
+
+func TestEndpointPageResponseMultiArrayObjectUsesNonResumablePreview(t *testing.T) {
+ items := make([]map[string]string, 0, MaxItems+25)
+ users := make([]map[string]string, 0, MaxItems+25)
+ for i := 0; i < MaxItems+25; i++ {
+ items = append(items, map[string]string{"id": "item-" + strconv.Itoa(i), "payload": strings.Repeat("x", 1600)})
+ users = append(users, map[string]string{"id": "user-" + strconv.Itoa(i), "payload": strings.Repeat("y", 1600)})
+ }
+ data, err := json.Marshal(map[string]any{
+ "items": items,
+ "users": users,
+ })
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := EndpointPageResponse("GET", data, PageOptions{CursorParam: "cursor"})
+ if len(text) > MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Truncated bool `json:"truncated"`
+ Resumable *bool `json:"resumable"`
+ Preview string `json:"preview"`
+ NextCursor string `json:"next_cursor"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("preview result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("multi-array preview should be marked truncated: %s", text)
+ }
+ if envelope.Resumable == nil || *envelope.Resumable {
+ t.Fatalf("multi-array preview should explicitly be non-resumable: %s", text)
+ }
+ if envelope.NextCursor != "" {
+ t.Fatalf("non-resumable preview must not include next_cursor: %s", text)
+ }
+ if envelope.Preview == "" {
+ t.Fatalf("non-resumable preview should include bounded preview text: %s", text)
+ }
+}
+
+func TestEndpointResponseTruncatedByItemLimitDoesNotClaimByteOverflow(t *testing.T) {
+ items := make([]string, 0, MaxItems+1)
+ for i := 0; i < MaxItems+1; i++ {
+ items = append(items, "ok")
+ }
+ data, err := json.Marshal(items)
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+ if len(data) > MaxBytes {
+ t.Fatalf("fixture should fit byte budget: %d > %d", len(data), MaxBytes)
+ }
+
+ text := EndpointResponse("GET", data)
+ var envelope struct {
+ Truncated bool `json:"truncated"`
+ ReturnedCount int `json:"returned_count"`
+ OriginalBytes int `json:"original_bytes"`
+ MaxBytes int `json:"max_bytes"`
+ Note string `json:"note"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded list result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded list result did not mark truncation: %s", text)
+ }
+ if envelope.ReturnedCount != MaxItems {
+ t.Fatalf("returned_count = %d, want %d", envelope.ReturnedCount, MaxItems)
+ }
+ if envelope.OriginalBytes > envelope.MaxBytes {
+ t.Fatalf("fixture should be item-limited, not byte-limited: original=%d max=%d", envelope.OriginalBytes, envelope.MaxBytes)
+ }
+ if strings.Contains(envelope.Note, "exceeded") {
+ t.Fatalf("item-limited note should not claim byte overflow: %q", envelope.Note)
+ }
+}
+
+func TestEndpointResponseBoundsSingleArrayEnvelope(t *testing.T) {
+ groups := make([]map[string]string, 0, MaxItems+25)
+ for i := 0; i < MaxItems+25; i++ {
+ groups = append(groups, map[string]string{
+ "id": strings.Repeat("g", 8),
+ "name": strings.Repeat("verbose group name ", 90),
+ })
+ }
+ data, err := json.Marshal(map[string]any{
+ "groups": groups,
+ "cursor": "next-page",
+ })
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := EndpointResponse("GET", data)
+ if len(text) > MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Groups []json.RawMessage `json:"groups"`
+ Cursor string `json:"cursor"`
+ Truncated bool `json:"_pp_truncated"`
+ TotalCount int `json:"_pp_total_count"`
+ ReturnedCount int `json:"_pp_returned_count"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded envelope result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded envelope result did not mark truncation: %s", text)
+ }
+ if envelope.Cursor != "next-page" {
+ t.Fatalf("cursor = %q, want preserved metadata", envelope.Cursor)
+ }
+ if envelope.TotalCount != len(groups) {
+ t.Fatalf("total_count = %d, want %d", envelope.TotalCount, len(groups))
+ }
+ if envelope.ReturnedCount != len(envelope.Groups) {
+ t.Fatalf("returned_count = %d, want group count %d", envelope.ReturnedCount, len(envelope.Groups))
+ }
+}
+
+func TestEndpointResponseFallsBackToOversizedPreview(t *testing.T) {
+ data, err := json.Marshal(map[string]string{
+ "blob": strings.Repeat("z", MaxBytes+10000),
+ })
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := EndpointResponse("GET", data)
+ if len(text) > MaxBytes {
+ t.Fatalf("preview result length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Truncated bool `json:"truncated"`
+ OriginalBytes int `json:"original_bytes"`
+ Preview string `json:"preview"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("preview result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("preview result did not mark truncation: %s", text)
+ }
+ if envelope.OriginalBytes != len(data) {
+ t.Fatalf("original_bytes = %d, want %d", envelope.OriginalBytes, len(data))
+ }
+ if envelope.Preview == "" {
+ t.Fatalf("preview result should include a bounded preview")
+ }
+}
+
+func TestEndpointResponseBoundsOversizedNonGETResponses(t *testing.T) {
+ data, err := json.Marshal(map[string]string{
+ "blob": strings.Repeat("z", MaxBytes+10000),
+ })
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := EndpointResponse("POST", data)
+ if len(text) > MaxBytes {
+ t.Fatalf("preview result length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Truncated bool `json:"truncated"`
+ OriginalBytes int `json:"original_bytes"`
+ Preview string `json:"preview"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("preview result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("preview result did not mark truncation: %s", text)
+ }
+ if envelope.OriginalBytes != len(data) {
+ t.Fatalf("original_bytes = %d, want %d", envelope.OriginalBytes, len(data))
+ }
+ if envelope.Preview == "" {
+ t.Fatalf("preview result should include a bounded preview")
+ }
+}
+
+func TestJSONBoundsLargeArrays(t *testing.T) {
+ rows := make([]map[string]string, 0, MaxItems+30)
+ for i := 0; i < MaxItems+30; i++ {
+ rows = append(rows, map[string]string{
+ "id": strings.Repeat("id", 80),
+ "text": strings.Repeat("search result payload ", 90),
+ })
+ }
+
+ text, err := JSON(rows)
+ if err != nil {
+ t.Fatalf("JSON returned error: %v", err)
+ }
+ if len(text) > MaxBytes {
+ t.Fatalf("bounded JSON length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Count int `json:"count"`
+ Items []json.RawMessage `json:"items"`
+ Truncated bool `json:"truncated"`
+ ReturnedCount int `json:"returned_count"`
+ OriginalBytes int `json:"original_bytes"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded JSON result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded JSON result did not mark truncation: %s", text)
+ }
+ if envelope.Count != len(rows) {
+ t.Fatalf("count = %d, want %d", envelope.Count, len(rows))
+ }
+ if envelope.ReturnedCount != len(envelope.Items) {
+ t.Fatalf("returned_count = %d, want item count %d", envelope.ReturnedCount, len(envelope.Items))
+ }
+ if envelope.OriginalBytes == 0 {
+ t.Fatalf("original_bytes should be populated")
+ }
+}
+
+func TestJSONBoundsSingleArrayObject(t *testing.T) {
+ results := make([]map[string]string, 0, MaxItems+25)
+ for i := 0; i < MaxItems+25; i++ {
+ results = append(results, map[string]string{
+ "id": "row-" + strconv.Itoa(i),
+ "payload": strings.Repeat("result payload ", 160),
+ })
+ }
+
+ text, err := JSON(map[string]any{
+ "count": len(results),
+ "results": results,
+ "store_status": "ready",
+ "resumable": false,
+ })
+ if err != nil {
+ t.Fatalf("JSON returned error: %v", err)
+ }
+ if len(text) > MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(text), MaxBytes)
+ }
+ var envelope struct {
+ Results []json.RawMessage `json:"results"`
+ StoreStatus string `json:"store_status"`
+ Truncated bool `json:"_pp_truncated"`
+ ReturnedCount int `json:"_pp_returned_count"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded single-array object must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded single-array object did not mark truncation: %s", text)
+ }
+ if envelope.StoreStatus != "ready" {
+ t.Fatalf("store_status = %q, want preserved metadata", envelope.StoreStatus)
+ }
+ if envelope.ReturnedCount != len(envelope.Results) {
+ t.Fatalf("returned_count = %d, want result count %d", envelope.ReturnedCount, len(envelope.Results))
+ }
+}
+
+func TestJSONReturnsCompactFullArrayWhenIndentedOutputExceedsBudget(t *testing.T) {
+ rows := make([]map[string]string, 11000)
+
+ text, err := JSON(rows)
+ if err != nil {
+ t.Fatalf("JSON returned error: %v", err)
+ }
+ if len(text) > MaxBytes {
+ t.Fatalf("compact JSON length = %d, want <= %d", len(text), MaxBytes)
+ }
+ if strings.Contains(text, `"truncated"`) {
+ t.Fatalf("compact full result should not be marked truncated: %s", text[:200])
+ }
+
+ var out []map[string]string
+ if err := json.Unmarshal([]byte(text), &out); err != nil {
+ t.Fatalf("compact full result must remain an array: %v\n%s", err, text[:200])
+ }
+ if len(out) != len(rows) {
+ t.Fatalf("returned rows = %d, want %d", len(out), len(rows))
+ }
+}
+
+func TestTextBoundsLargeCommandOutput(t *testing.T) {
+ text := Text(strings.Repeat("plain output line\n", 5000))
+ if len(text) > MaxBytes {
+ t.Fatalf("bounded text length = %d, want <= %d", len(text), MaxBytes)
+ }
+
+ var envelope struct {
+ Truncated bool `json:"truncated"`
+ OriginalBytes int `json:"original_bytes"`
+ Preview string `json:"preview"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded text result must be a JSON envelope: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded text result did not mark truncation: %s", text)
+ }
+ if envelope.OriginalBytes == 0 {
+ t.Fatalf("original_bytes should be populated")
+ }
+ if envelope.Preview == "" {
+ t.Fatalf("preview should be populated")
+ }
+}
+
+func TestTextPreviewPreservesUTF8RuneBoundaries(t *testing.T) {
+ text := Text(strings.Repeat("€", MaxBytes))
+
+ var envelope struct {
+ Preview string `json:"preview"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded text result must be a JSON envelope: %v\n%s", err, text)
+ }
+ if envelope.Preview == "" {
+ t.Fatalf("preview should be populated")
+ }
+ if !utf8.ValidString(envelope.Preview) {
+ t.Fatalf("preview should remain valid UTF-8")
+ }
+ if strings.ContainsRune(envelope.Preview, utf8.RuneError) {
+ t.Fatalf("preview introduced a replacement character at a split UTF-8 boundary: %q", envelope.Preview[len(envelope.Preview)-12:])
+ }
+}
diff --git a/library/productivity/human-goat/internal/mcp/cobratree/classify.go b/library/productivity/human-goat/internal/mcp/cobratree/classify.go
new file mode 100644
index 0000000000..0597d22443
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/cobratree/classify.go
@@ -0,0 +1,155 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ EndpointAnnotation = "pp:endpoint"
+ HiddenAnnotation = "mcp:hidden"
+ // ReadOnlyAnnotation, when set on a Cobra command to "true"/"1"/"yes",
+ // causes the runtime walker to register the resulting MCP tool with
+ // readOnlyHint=true. Use for novel CLI commands that don't mutate
+ // external state — read-only API queries, local cache reads, etc.
+ // Without it, hosts like Claude Desktop default to "could write or
+ // delete" and demand permission per call.
+ ReadOnlyAnnotation = "mcp:read-only"
+ // LocalWriteAnnotation, when set on a Cobra command to "true"/"1"/"yes",
+ // marks a command whose only writes land in the CLI's own local store
+ // (teach-style learn writes, playbook notes) - never external state and
+ // never user-visible files. The walker registers the resulting MCP tool
+ // with destructiveHint=false and openWorldHint=false; readOnlyHint stays
+ // unset because the command genuinely writes locally. Commands that
+ // delete user-visible data keep honest destructive semantics and must
+ // not carry this annotation.
+ LocalWriteAnnotation = "mcp:local-write"
+ // PositionalWriteSinksAnnotation lists zero-based positional argument
+ // indexes that write to user-visible files when populated. It is enforced
+ // only on commands that also carry ReadOnlyAnnotation.
+ PositionalWriteSinksAnnotation = "mcp:write-positionals"
+)
+
+type commandKind int
+
+const (
+ commandNovel commandKind = iota
+ commandEndpoint
+ commandFramework
+ commandHidden
+)
+
+// frameworkCommands are top-level CLI commands the walker should skip when
+// mirroring the Cobra tree. Two cases qualify:
+//
+// 1. A typed MCP tool already covers the same capability (the typed tool's
+// schema is strictly better than a shell-out). Examples:
+// `context`/`about`/`agent-context`, `api` (endpoint mirror tools cover it).
+// 2. The command is non-functional via MCP (interactive setup, shell-only
+// ergonomics, trivial introspection, local-only feedback). Examples:
+// `auth`, `completion`, `doctor`, `version`, `feedback`, `profile`,
+// `which`, `help`.
+//
+// Commands that DO have agent value — `sync` (populates the store that `sql`
+// and `search` query), `stale`/`orphans`/`reconcile`/`load` (store
+// diagnostics), `export`/`import` (data movement), `workflow`
+// (compound operations), `analytics` (aggregations) — must NOT be in this
+// list. Excluding `sync` while exposing `sql` is a broken contract because
+// the typed `sql` tool returns empty results until something populates the
+// store. See AGENTS.md "Agent-Native Surface" for the principle.
+//
+// Adding a new generator-emitted command means deciding which of the two
+// cases above applies. When in doubt, leave it out — the walker registers
+// any user-facing command as a shell-out tool, and the cost of a slightly
+// underused tool is much smaller than the cost of a broken contract like
+// `sql` without `sync`.
+var frameworkCommands = map[string]bool{
+ "about": true,
+ "agent-context": true,
+ "api": true,
+ "auth": true,
+ "completion": true,
+ "doctor": true,
+ "feedback": true,
+ "help": true,
+ "profile": true,
+ "version": true,
+ "which": true,
+}
+
+func classify(cmd *cobra.Command) commandKind {
+ if cmd == nil || cmd.Hidden || isMCPHidden(cmd) {
+ return commandHidden
+ }
+ if endpointID(cmd) != "" {
+ return commandEndpoint
+ }
+ if isTopLevelFrameworkCommand(cmd) {
+ return commandFramework
+ }
+ return commandNovel
+}
+
+func isTopLevelFrameworkCommand(cmd *cobra.Command) bool {
+ if cmd == nil || !frameworkCommands[cmd.Name()] {
+ return false
+ }
+ parent := cmd.Parent()
+ return parent != nil && parent.Parent() == nil
+}
+
+func endpointID(cmd *cobra.Command) string {
+ if cmd == nil || cmd.Annotations == nil {
+ return ""
+ }
+ return strings.TrimSpace(cmd.Annotations[EndpointAnnotation])
+}
+
+func isMCPHidden(cmd *cobra.Command) bool {
+ return annotationIsTrue(cmd, HiddenAnnotation)
+}
+
+func isMCPReadOnly(cmd *cobra.Command) bool {
+ return annotationIsTrue(cmd, ReadOnlyAnnotation)
+}
+
+func isMCPLocalWrite(cmd *cobra.Command) bool {
+ return annotationIsTrue(cmd, LocalWriteAnnotation)
+}
+
+func positionalWriteSinkIndexes(cmd *cobra.Command) map[int]bool {
+ if cmd == nil || cmd.Annotations == nil {
+ return nil
+ }
+ raw := strings.TrimSpace(cmd.Annotations[PositionalWriteSinksAnnotation])
+ if raw == "" {
+ return nil
+ }
+ out := map[int]bool{}
+ for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
+ return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n'
+ }) {
+ idx, err := strconv.Atoi(strings.TrimSpace(part))
+ if err != nil || idx < 0 {
+ continue
+ }
+ out[idx] = true
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func annotationIsTrue(cmd *cobra.Command, key string) bool {
+ if cmd == nil || cmd.Annotations == nil {
+ return false
+ }
+ v := strings.ToLower(strings.TrimSpace(cmd.Annotations[key]))
+ return v == "true" || v == "1" || v == "yes"
+}
diff --git a/library/productivity/human-goat/internal/mcp/cobratree/cli_path.go b/library/productivity/human-goat/internal/mcp/cobratree/cli_path.go
new file mode 100644
index 0000000000..22331ceeb7
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/cobratree/cli_path.go
@@ -0,0 +1,44 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+)
+
+// SiblingCLIPath resolves the companion CLI via sibling-of-executable,
+// HUMAN_GOAT_CLI_PATH env var, then PATH.
+func SiblingCLIPath() (string, error) {
+ if exe, err := os.Executable(); err == nil {
+ for _, candidate := range siblingCLICandidates(runtime.GOOS, exe) {
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate, nil
+ }
+ }
+ }
+ if v := os.Getenv("HUMAN_GOAT_CLI_PATH"); v != "" {
+ return v, nil
+ }
+ return exec.LookPath(cliExecutableName(runtime.GOOS))
+}
+
+func siblingCLICandidates(goos, exePath string) []string {
+ dir := filepath.Dir(exePath)
+ name := "human-goat-pp-cli"
+ if goos == "windows" {
+ return []string{filepath.Join(dir, name+".exe"), filepath.Join(dir, name)}
+ }
+ return []string{filepath.Join(dir, name)}
+}
+
+func cliExecutableName(goos string) string {
+ name := "human-goat-pp-cli"
+ if goos == "windows" {
+ return name + ".exe"
+ }
+ return name
+}
diff --git a/library/productivity/human-goat/internal/mcp/cobratree/names.go b/library/productivity/human-goat/internal/mcp/cobratree/names.go
new file mode 100644
index 0000000000..9ca5607451
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/cobratree/names.go
@@ -0,0 +1,25 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+ "strings"
+ "unicode"
+)
+
+func toolNameForPath(parts []string) string {
+ var out []rune
+ for _, part := range parts {
+ for _, r := range part {
+ switch {
+ case unicode.IsLetter(r) || unicode.IsDigit(r):
+ out = append(out, unicode.ToLower(r))
+ default:
+ out = append(out, '_')
+ }
+ }
+ out = append(out, '_')
+ }
+ return strings.Trim(strings.Join(strings.FieldsFunc(string(out), func(r rune) bool { return r == '_' }), "_"), "_")
+}
diff --git a/library/productivity/human-goat/internal/mcp/cobratree/shellout.go b/library/productivity/human-goat/internal/mcp/cobratree/shellout.go
new file mode 100644
index 0000000000..8ad4776390
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/cobratree/shellout.go
@@ -0,0 +1,281 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os/exec"
+ "sort"
+ "strconv"
+ "strings"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/mcp/bound"
+)
+
+func shellOutToCLI(cliPath func() (string, error), commandPath []string, blockedStructuredArgs map[string]bool, allowedStructuredArgs map[string]bool, positionals []positionalArg, readOnly bool, positionalWriteSinks map[int]bool) server.ToolHandlerFunc {
+ lookupPath, lookupErr := cliPath()
+ prefixArgs := append([]string{}, commandPath...)
+ return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ if lookupErr != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("companion CLI binary not found: %v\nTried sibling lookup, HUMAN_GOAT_CLI_PATH env var, and PATH.", lookupErr)), nil
+ }
+ args := req.GetArguments()
+ if err := validateMCPArgumentNames(args, allowedStructuredArgs); err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+ finalArgs := append([]string{}, prefixArgs...)
+ finalArgs = append(finalArgs, cliArgsFromMCP(args, blockedStructuredArgs)...)
+ positionalArgs, err := positionalArgsFromMCP(args, positionals, readOnly, positionalWriteSinks)
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+ finalArgs = append(finalArgs, positionalArgs...)
+ if raw, _ := args["args"].(string); strings.TrimSpace(raw) != "" {
+ rawPositionals := positionalArgsFromRawArgsField(raw, positionals, len(positionalArgs))
+ if err := validatePositionalArgsForMCPAtOffset(rawPositionals, readOnly, positionalWriteSinks, len(positionalArgs)); err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+ finalArgs = append(finalArgs, rawPositionals...)
+ }
+ out, err := RunCLICommand(ctx, lookupPath, finalArgs)
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+ return mcplib.NewToolResultText(bound.Text(out)), nil
+ }
+}
+
+func validateMCPArgumentNames(args map[string]any, allowed map[string]bool) error {
+ if len(args) == 0 {
+ return nil
+ }
+ var unknown []string
+ for k := range args {
+ if !allowed[k] {
+ unknown = append(unknown, k)
+ }
+ }
+ if len(unknown) == 0 {
+ return nil
+ }
+ sort.Strings(unknown)
+ if len(unknown) == 1 {
+ return fmt.Errorf("unknown MCP parameter %q; use the tool schema's named parameters", unknown[0])
+ }
+ return fmt.Errorf("unknown MCP parameters %s; use the tool schema's named parameters", quoteMCPParameterNames(unknown))
+}
+
+func quoteMCPParameterNames(names []string) string {
+ quoted := make([]string, len(names))
+ for i, name := range names {
+ quoted[i] = strconv.Quote(name)
+ }
+ return strings.Join(quoted, ", ")
+}
+
+func positionalArgsFromMCP(args map[string]any, positionals []positionalArg, readOnly bool, positionalWriteSinks map[int]bool) ([]string, error) {
+ out := make([]string, 0, len(positionals))
+ for positionalIndex, positional := range positionals {
+ value, ok := args[positional.InputName]
+ if !ok || value == nil {
+ continue
+ }
+ var text string
+ switch tv := value.(type) {
+ case string:
+ text = tv
+ case float64:
+ text = strconv.FormatFloat(tv, 'f', -1, 64)
+ case bool:
+ text = strconv.FormatBool(tv)
+ default:
+ text = fmt.Sprintf("%v", tv)
+ }
+ if strings.TrimSpace(text) == "" {
+ continue
+ }
+ if positionalIndex != len(out) {
+ return nil, fmt.Errorf("structured positional %q cannot be used while earlier positional arguments are omitted; provide the preceding positional arguments or use the args field", positional.InputName)
+ }
+ if text != "-" && strings.HasPrefix(text, "-") {
+ return nil, fmt.Errorf("flag-like positional argument %q not allowed in structured positional %q; use structured flag parameters instead", text, positional.InputName)
+ }
+ if readOnly && positionalWriteSinks[positionalIndex] && text != "-" {
+ return nil, fmt.Errorf("positional argument %d writes to %q; file output is not available for read-only MCP tools", positionalIndex+1, text)
+ }
+ out = append(out, text)
+ }
+ if err := validatePositionalArgsForMCP(out, readOnly, nil); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func positionalArgsFromRawArgsField(raw string, positionals []positionalArg, structuredCount int) []string {
+ text := strings.TrimSpace(raw)
+ if text == "" {
+ return nil
+ }
+ if len(positionals) == 1 && structuredCount == 0 {
+ return []string{text}
+ }
+ return SplitShellArgs(raw)
+}
+
+func validatePositionalArgsForMCP(tokens []string, readOnly bool, positionalWriteSinks map[int]bool) error {
+ return validatePositionalArgsForMCPAtOffset(tokens, readOnly, positionalWriteSinks, 0)
+}
+
+func validatePositionalArgsForMCPAtOffset(tokens []string, readOnly bool, positionalWriteSinks map[int]bool, offset int) error {
+ for _, t := range tokens {
+ if t != "-" && strings.HasPrefix(t, "-") {
+ return fmt.Errorf("flag-like argument %q not allowed in positional args field; use structured tool parameters instead", t)
+ }
+ }
+ if !readOnly || len(positionalWriteSinks) == 0 {
+ return nil
+ }
+ for i, t := range tokens {
+ positionalIndex := offset + i
+ if !positionalWriteSinks[positionalIndex] {
+ continue
+ }
+ if strings.TrimSpace(t) == "" || t == "-" {
+ continue
+ }
+ return fmt.Errorf("positional argument %d writes to %q; file output is not available for read-only MCP tools", positionalIndex+1, t)
+ }
+ return nil
+}
+
+// reservedStructuredArgs are MCP-only argument names that must never be
+// forwarded as CLI flags.
+var reservedStructuredArgs = map[string]bool{
+ "args": true,
+}
+
+// blockedRootFlags are root-level CLI flags that an MCP client must not be
+// able to override via structured tool parameters. Allowing them lets a
+// caller swap auth credentials, redirect the API base URL, select a different
+// per-client filesystem, relocate the config/data/state/cache roots, load a
+// malicious config file, or change the delivery target, all of which sit
+// outside the per-command surface the agent is supposed to be calling.
+var blockedRootFlags = map[string]bool{
+ "base-url": true,
+ "client": true,
+ "config": true,
+ "deliver": true,
+ "home": true,
+ "profile": true,
+ "token": true,
+}
+
+func cliArgsFromMCP(args map[string]any, blocked map[string]bool) []string {
+ keys := make([]string, 0, len(args))
+ for k := range args {
+ if strings.Contains(k, "=") {
+ continue
+ }
+ if blocked[k] {
+ continue
+ }
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ var out []string
+ for _, k := range keys {
+ v := args[k]
+ switch tv := v.(type) {
+ case bool:
+ if tv {
+ out = append(out, "--"+k)
+ }
+ case float64:
+ out = append(out, "--"+k, strconv.FormatFloat(tv, 'f', -1, 64))
+ case string:
+ if tv != "" {
+ out = append(out, "--"+k, tv)
+ }
+ case []any:
+ if len(tv) > 0 {
+ parts := make([]string, 0, len(tv))
+ for _, item := range tv {
+ parts = append(parts, fmt.Sprintf("%v", item))
+ }
+ out = append(out, "--"+k, strings.Join(parts, ","))
+ }
+ default:
+ if v != nil {
+ out = append(out, "--"+k, fmt.Sprintf("%v", v))
+ }
+ }
+ }
+ return out
+}
+
+// SplitShellArgs whitespace-splits with shell-style quote preservation.
+func SplitShellArgs(s string) []string {
+ var tokens []string
+ var cur []rune
+ inSingleQuote := false
+ inDoubleQuote := false
+ escaped := false
+ for _, r := range s {
+ switch {
+ case escaped:
+ cur = append(cur, r)
+ escaped = false
+ case r == '\\' && !inSingleQuote:
+ escaped = true
+ case r == '\'' && !inDoubleQuote:
+ inSingleQuote = !inSingleQuote
+ case r == '"' && !inSingleQuote:
+ inDoubleQuote = !inDoubleQuote
+ case (r == ' ' || r == '\t') && !inSingleQuote && !inDoubleQuote:
+ if len(cur) > 0 {
+ tokens = append(tokens, string(cur))
+ cur = cur[:0]
+ }
+ default:
+ cur = append(cur, r)
+ }
+ }
+ if escaped {
+ cur = append(cur, '\\')
+ }
+ if len(cur) > 0 {
+ tokens = append(tokens, string(cur))
+ }
+ return tokens
+}
+
+// RunCLICommand executes the companion CLI while preserving stdout as the
+// machine-readable channel. Stderr is included only in error text so post-run
+// telemetry or quota output cannot corrupt JSON results.
+func RunCLICommand(ctx context.Context, binPath string, args []string) (string, error) {
+ cmd := exec.CommandContext(ctx, binPath, args...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ msg := strings.TrimSpace(stderr.String())
+ if msg == "" {
+ msg = strings.TrimSpace(stdout.String())
+ }
+ if msg != "" {
+ label := "stderr"
+ if strings.TrimSpace(stderr.String()) == "" {
+ label = "output"
+ }
+ return stdout.String(), fmt.Errorf("cli %s: %w (%s: %s)", binPath, err, label, msg)
+ }
+ return stdout.String(), fmt.Errorf("cli %s: %w", binPath, err)
+ }
+ return stdout.String(), nil
+}
diff --git a/library/productivity/human-goat/internal/mcp/cobratree/shellout_test.go b/library/productivity/human-goat/internal/mcp/cobratree/shellout_test.go
new file mode 100644
index 0000000000..3d79584326
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/cobratree/shellout_test.go
@@ -0,0 +1,702 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/spf13/cobra"
+)
+
+// TestSplitShellArgs pins the whitespace + quote splitting used by
+// the args-field passthrough. Behavior we rely on: bare whitespace splits,
+// tabs split, quoted spans stay together, empty input yields nil.
+func TestSplitShellArgs(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want []string
+ }{
+ {"empty", "", nil},
+ {"single token", "contacts", []string{"contacts"}},
+ {"two tokens", "inbox health", []string{"inbox", "health"}},
+ {"extra whitespace", " foo bar ", []string{"foo", "bar"}},
+ {"tabs", "foo\tbar", []string{"foo", "bar"}},
+ {"quoted token", `"hello world"`, []string{"hello world"}},
+ {"mixed quoted and bare", `contacts "john doe" active`, []string{"contacts", "john doe", "active"}},
+ {"double quoted apostrophe", `"My Club's Night"`, []string{"My Club's Night"}},
+ {"single quoted span", `'a b' c`, []string{"a b", "c"}},
+ {"escaped whitespace", `a\ b c`, []string{"a b", "c"}},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ got := SplitShellArgs(tc.in)
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Errorf("splitShellArgs(%q) = %v, want %v", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestCliArgsFromMCP_BlocksRootFlags pins the structured-parameter half of
+// the control-plane-injection guard: even when an MCP client wraps the
+// flag in the structured args map (instead of the free-form "args"
+// string), the root flags listed in blockedRootFlags must be dropped
+// before they reach exec.CommandContext. A regression here would let a
+// caller redirect --base-url, swap --token, switch --client filesystems,
+// relocate the CLI's filesystem roots via --home, or load a malicious
+// --config.
+func TestCliArgsFromMCP_BlocksRootFlags(t *testing.T) {
+ in := map[string]any{
+ "args": "contacts",
+ "base-url": "https://evil.example.com",
+ "client": "attacker-client",
+ "config": "/tmp/evil.yaml",
+ "deliver": "fd:3",
+ "home": "/tmp/evil-home",
+ "profile": "attacker",
+ "token": "stolen-token",
+ // Keys containing "=" must not be emitted verbatim as flag=value.
+ "base-url=https://evil.example.com": true,
+ "config=/tmp/evil.yaml": true,
+ "limit=99": float64(42),
+ "token=stolen-token": true,
+ // Allowed per-command flag passes through.
+ "limit": float64(10),
+ }
+ got := cliArgsFromMCP(in, map[string]bool{
+ "args": true,
+ "base-url": true,
+ "client": true,
+ "config": true,
+ "deliver": true,
+ "home": true,
+ "profile": true,
+ "token": true,
+ })
+ want := []string{"--limit", "10"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("cliArgsFromMCP dropped/kept wrong keys: got %v, want %v", got, want)
+ }
+ for _, blocked := range []string{"--base-url", "--client", "--config", "--deliver", "--home", "--profile", "--token", "--args"} {
+ for _, tok := range got {
+ if tok == blocked {
+ t.Errorf("blocked flag %q leaked through cliArgsFromMCP", blocked)
+ }
+ }
+ }
+}
+
+// TestCliArgsFromMCP_AllowsPerCommandFlags is the don't-overcorrect half:
+// any flag NOT in blockedRootFlags must still pass through, including
+// strings, bools, numbers, and []any. Without this, a tightening change
+// to the blocklist that accidentally drops legitimate per-command flags
+// would silently break every MCP-driven command. cliArgsFromMCP also
+// guarantees sorted-key output (so generated commands see deterministic
+// argv ordering); compare directly to catch a regression in either the
+// blocklist or the sort.
+func TestCliArgsFromMCP_AllowsPerCommandFlags(t *testing.T) {
+ in := map[string]any{
+ "query": "alpha",
+ "verbose": true,
+ "limit": float64(25),
+ "tags": []any{"a", "b"},
+ }
+ got := cliArgsFromMCP(in, map[string]bool{"args": true})
+ want := []string{"--limit", "25", "--query", "alpha", "--tags", "a,b", "--verbose"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("cliArgsFromMCP per-command passthrough: got %v, want %v", got, want)
+ }
+}
+
+func TestValidateMCPArgumentNamesRejectsArgsWhenNoParamsAllowed(t *testing.T) {
+ err := validateMCPArgumentNames(map[string]any{"query": "alpha"}, nil)
+ if err == nil {
+ t.Fatal("validateMCPArgumentNames accepted an unknown arg for a parameter-free command")
+ }
+ if got, want := err.Error(), `unknown MCP parameter "query"; use the tool schema's named parameters`; got != want {
+ t.Fatalf("validateMCPArgumentNames error = %q, want %q", got, want)
+ }
+}
+
+func TestValidateMCPArgumentNamesQuotesEachUnknownArg(t *testing.T) {
+ err := validateMCPArgumentNames(map[string]any{
+ "zeta": true,
+ "alpha": "x",
+ }, map[string]bool{})
+ if err == nil {
+ t.Fatal("validateMCPArgumentNames accepted unknown args")
+ }
+ if got, want := err.Error(), `unknown MCP parameters "alpha", "zeta"; use the tool schema's named parameters`; got != want {
+ t.Fatalf("validateMCPArgumentNames error = %q, want %q", got, want)
+ }
+}
+
+func TestBlockedStructuredArgsOnlyDropsInheritedRootFlags(t *testing.T) {
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().String("profile", "", "root profile")
+ root.PersistentFlags().String("config", "", "root config")
+ root.PersistentFlags().String("json", "", "format output")
+
+ child := &cobra.Command{Use: "child"}
+ child.Flags().String("profile", "", "command profile")
+ root.AddCommand(child)
+
+ blocked := blockedStructuredArgsForCommand(child)
+ if blocked["profile"] {
+ t.Fatalf("command-local --profile was blocked as a root flag: %#v", blocked)
+ }
+ if !blocked["config"] {
+ t.Fatalf("inherited root --config was not blocked: %#v", blocked)
+ }
+ if blocked["json"] {
+ t.Fatalf("non-sensitive inherited --json should remain available: %#v", blocked)
+ }
+
+ got := cliArgsFromMCP(map[string]any{
+ "args": "ignored",
+ "profile": "local-profile",
+ "config": "/tmp/evil.yaml",
+ "json": "true",
+ }, blocked)
+ want := []string{"--json", "true", "--profile", "local-profile"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("cliArgsFromMCP command-aware blocklist: got %v, want %v", got, want)
+ }
+}
+
+// TestArgsFieldRejectsFlagLikeTokens covers the free-form "args" string
+// half of the control-plane-injection guard. shellOutToCLI is a closure
+// that requires a real binary on PATH; we exercise the same guard logic
+// here so a regression that drops the strings.HasPrefix("-") check is
+// caught at unit-test scope rather than only via end-to-end MCP runs.
+func TestArgsFieldRejectsFlagLikeTokens(t *testing.T) {
+ guard := func(raw string) (rejected string, ok bool) {
+ tokens := SplitShellArgs(raw)
+ err := validatePositionalArgsForMCP(tokens, false, nil)
+ if err == nil {
+ return "", true
+ }
+ for _, t := range tokens {
+ if t != "-" && strings.HasPrefix(t, "-") {
+ return t, false
+ }
+ }
+ return "", false
+ }
+ cases := []struct {
+ name string
+ in string
+ wantOK bool
+ wantBlocked string
+ }{
+ {"long flag", "--config /tmp/evil.yaml", false, "--config"},
+ {"short flag", "-c", false, "-c"},
+ {"bare double dash", "--", false, "--"},
+ {"flag with equals", "--config=/tmp/evil.yaml", false, "--config=/tmp/evil.yaml"},
+ {"flag mid-args", "contacts --verbose", false, "--verbose"},
+ {"clean positional", "contacts", true, ""},
+ {"two positionals", "inbox health", true, ""},
+ {"empty", "", true, ""},
+ // Shell metachars in unquoted positional content stay verbatim;
+ // the CLI is invoked via exec.CommandContext (no /bin/sh), so
+ // $VAR / && / | are inert. Pin that they don't trip the guard.
+ {"shell metachars in positional", `name with $VAR && pipe|stuff`, true, ""},
+ {"lone dash allowed as stdout escape", "-", true, ""},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ tok, ok := guard(tc.in)
+ if ok != tc.wantOK {
+ t.Errorf("guard(%q) ok = %v, want %v", tc.in, ok, tc.wantOK)
+ }
+ if !tc.wantOK && tok != tc.wantBlocked {
+ t.Errorf("guard(%q) blocked = %q, want %q", tc.in, tok, tc.wantBlocked)
+ }
+ })
+ }
+}
+
+func TestReadOnlyPositionalWriteSinksRejectFileOutput(t *testing.T) {
+ cases := []struct {
+ name string
+ raw string
+ readOnly bool
+ sinks map[int]bool
+ wantError bool
+ }{
+ {"unmarked read-only positional stays allowed", "out.json", true, nil, false},
+ {"write sink rejects file path", "out.json", true, map[int]bool{0: true}, true},
+ {"write sink permits stdout dash", "-", true, map[int]bool{0: true}, false},
+ {"write sink ignored outside read-only tool", "out.json", false, map[int]bool{0: true}, false},
+ {"later write sink rejects second positional", "team out.json", true, map[int]bool{1: true}, true},
+ {"later write sink allows missing optional positional", "team", true, map[int]bool{1: true}, false},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ err := validatePositionalArgsForMCP(SplitShellArgs(tc.raw), tc.readOnly, tc.sinks)
+ if tc.wantError && err == nil {
+ t.Fatalf("validatePositionalArgsForMCP(%q) succeeded, want error", tc.raw)
+ }
+ if !tc.wantError && err != nil {
+ t.Fatalf("validatePositionalArgsForMCP(%q) returned error: %v", tc.raw, err)
+ }
+ })
+ }
+}
+
+func TestPositionalWriteSinkIndexesParsesAnnotation(t *testing.T) {
+ cmd := &cobra.Command{
+ Use: "export-unmatched [path]",
+ Annotations: map[string]string{
+ PositionalWriteSinksAnnotation: "0, 2;3",
+ },
+ }
+ got := positionalWriteSinkIndexes(cmd)
+ for _, idx := range []int{0, 2, 3} {
+ if !got[idx] {
+ t.Fatalf("positionalWriteSinkIndexes missing index %d from %#v", idx, got)
+ }
+ }
+ if got[1] {
+ t.Fatalf("positionalWriteSinkIndexes unexpectedly included index 1: %#v", got)
+ }
+}
+
+func TestPositionalsExposeNamedInputsAndRejectFlagLikeValues(t *testing.T) {
+ cmd := &cobra.Command{Use: "export [id]"}
+ positionals := positionalArgsForCommand(cmd, blockedStructuredArgsForCommand(cmd))
+ if got, want := len(positionals), 2; got != want {
+ t.Fatalf("positionals length = %d, want %d: %#v", got, want, positionals)
+ }
+ if positionals[0].InputName != "resource" || !positionals[0].Required {
+ t.Fatalf("first positional = %#v, want required resource", positionals[0])
+ }
+ if positionals[1].InputName != "id" || positionals[1].Required {
+ t.Fatalf("second positional = %#v, want optional id", positionals[1])
+ }
+
+ got, err := positionalArgsFromMCP(map[string]any{"resource": "contacts", "id": "123"}, positionals, false, nil)
+ if err != nil {
+ t.Fatalf("positionalArgsFromMCP returned error: %v", err)
+ }
+ if want := []string{"contacts", "123"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("positionals = %v, want %v", got, want)
+ }
+
+ if _, err := positionalArgsFromMCP(map[string]any{"resource": "--config"}, positionals, false, nil); err == nil {
+ t.Fatal("flag-like structured positional succeeded, want error")
+ }
+}
+
+func TestStructuredPositionalWriteSinkUsesOriginalCLIIndex(t *testing.T) {
+ cmd := &cobra.Command{Use: "export [format] "}
+ positionals := positionalArgsForCommand(cmd, blockedStructuredArgsForCommand(cmd))
+ if got, want := len(positionals), 2; got != want {
+ t.Fatalf("positionals length = %d, want %d: %#v", got, want, positionals)
+ }
+
+ if _, err := positionalArgsFromMCP(
+ map[string]any{"output-file": "out.json"},
+ positionals,
+ true,
+ map[int]bool{1: true},
+ ); err == nil {
+ t.Fatal("structured write sink with omitted optional positional succeeded, want error")
+ }
+
+ if _, err := positionalArgsFromMCP(
+ map[string]any{"output-file": "-"},
+ positionals,
+ true,
+ map[int]bool{1: true},
+ ); err == nil {
+ t.Fatal("stdout structured write sink with omitted optional positional succeeded, want error")
+ }
+
+ got, err := positionalArgsFromMCP(
+ map[string]any{"format": "json", "output-file": "-"},
+ positionals,
+ true,
+ map[int]bool{1: true},
+ )
+ if err != nil {
+ t.Fatalf("stdout structured write sink with contiguous positionals returned error: %v", err)
+ }
+ if want := []string{"json", "-"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("structured write sink args = %v, want %v", got, want)
+ }
+}
+
+func TestStructuredPositionalsRejectGapsBeforeLaterArguments(t *testing.T) {
+ cmd := &cobra.Command{Use: "export [format] "}
+ positionals := positionalArgsForCommand(cmd, blockedStructuredArgsForCommand(cmd))
+
+ if _, err := positionalArgsFromMCP(
+ map[string]any{"output-file": "report.csv"},
+ positionals,
+ false,
+ nil,
+ ); err == nil {
+ t.Fatal("later structured positional with omitted earlier positional succeeded, want error")
+ }
+
+ got, err := positionalArgsFromMCP(
+ map[string]any{"format": "csv", "output-file": "report.csv"},
+ positionals,
+ false,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("contiguous structured positionals returned error: %v", err)
+ }
+ if want := []string{"csv", "report.csv"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("contiguous structured positionals = %v, want %v", got, want)
+ }
+}
+
+func TestRawArgsWriteSinkValidationUsesStructuredPositionalOffset(t *testing.T) {
+ tokens := SplitShellArgs("evil.txt")
+ sinks := map[int]bool{1: true}
+ if err := validatePositionalArgsForMCPAtOffset(tokens, true, sinks, 1); err == nil {
+ t.Fatal("raw args write sink with structured positional offset succeeded, want error")
+ }
+ if err := validatePositionalArgsForMCPAtOffset(SplitShellArgs("-"), true, sinks, 1); err != nil {
+ t.Fatalf("stdout raw args with structured positional offset returned error: %v", err)
+ }
+}
+
+func TestCLIArgsFromMCPSkipsStructuredPositionals(t *testing.T) {
+ cmd := &cobra.Command{Use: "export [id]"}
+ positionals := positionalArgsForCommand(cmd, blockedStructuredArgsForCommand(cmd))
+ blocked := cliFlagBlockedArgs(map[string]bool{"args": true}, positionals)
+ got := cliArgsFromMCP(map[string]any{
+ "resource": "contacts",
+ "id": "123",
+ "format": "json",
+ }, blocked)
+ want := []string{"--format", "json"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("cliArgsFromMCP forwarded positionals as flags: got %v, want %v", got, want)
+ }
+}
+
+func TestToolOptionsHideBlockedRootFlagsButKeepLocalCollisions(t *testing.T) {
+ root := &cobra.Command{Use: "root"}
+ root.PersistentFlags().String("config", "", "root config")
+ root.PersistentFlags().Bool("json", false, "json output")
+
+ child := &cobra.Command{Use: "child "}
+ child.Flags().String("config", "", "local config")
+ child.Flags().String("args", "", "reserved local args")
+ root.AddCommand(child)
+
+ blocked := blockedStructuredArgsForCommand(child)
+ positionals := positionalArgsForCommand(child, blocked)
+ tool := mcplib.NewTool("child", toolOptionsForFlags(child, blocked, positionals)...)
+ props := tool.InputSchema.Properties
+ if _, ok := props["json"]; !ok {
+ t.Fatalf("non-sensitive inherited --json missing from schema: %#v", props)
+ }
+ if _, ok := props["config"]; !ok {
+ t.Fatalf("command-local --config collision missing from schema: %#v", props)
+ }
+ if _, ok := props["query"]; !ok {
+ t.Fatalf("positional missing from schema: %#v", props)
+ }
+ if _, ok := props["args"]; ok {
+ t.Fatalf("reserved args parameter should not be exposed as a flag schema: %#v", props)
+ }
+}
+
+func TestRegisterAllPreservesTypedToolsAndExposesHandBuiltSearchWithoutTypedEquivalent(t *testing.T) {
+ root := &cobra.Command{Use: "root"}
+ root.AddCommand(
+ &cobra.Command{
+ Use: "context",
+ RunE: func(cmd *cobra.Command, args []string) error { return nil },
+ },
+ &cobra.Command{
+ Use: "auth",
+ RunE: func(cmd *cobra.Command, args []string) error { return nil },
+ },
+ &cobra.Command{
+ Use: "search ",
+ RunE: func(cmd *cobra.Command, args []string) error { return nil },
+ },
+ &cobra.Command{
+ Use: "secret",
+ Annotations: map[string]string{"mcp:hidden": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error { return nil },
+ },
+ )
+
+ s := server.NewMCPServer("test", "0.0.0")
+ s.AddTool(mcplib.NewTool("context", mcplib.WithDescription("typed context")), func(context.Context, mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ return mcplib.NewToolResultText("typed"), nil
+ })
+ RegisterAll(s, root, func() (string, error) { return "missing-binary", nil })
+
+ tools := s.ListTools()
+ if tools["context"].Tool.Description != "typed context" {
+ t.Fatalf("typed context tool was overwritten: %#v", tools["context"].Tool)
+ }
+ if _, ok := tools["search"]; !ok {
+ t.Fatalf("hand-built search without typed search equivalent was not mirrored: %#v", tools)
+ }
+ for _, excluded := range []string{"auth", "secret"} {
+ if _, ok := tools[excluded]; ok {
+ t.Fatalf("excluded command %q was mirrored: %#v", excluded, tools)
+ }
+ }
+
+ sWithTypedSearch := server.NewMCPServer("test", "0.0.0")
+ sWithTypedSearch.AddTool(mcplib.NewTool("search", mcplib.WithDescription("typed search")), func(context.Context, mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ return mcplib.NewToolResultText("typed"), nil
+ })
+ RegisterAll(sWithTypedSearch, root, func() (string, error) { return "missing-binary", nil })
+ if got := sWithTypedSearch.ListTools()["search"].Tool.Description; got != "typed search" {
+ t.Fatalf("typed search tool was overwritten: %q", got)
+ }
+}
+
+func TestShellOutSinglePositionalArgsFieldPreservesWhitespace(t *testing.T) {
+ bin := writeArgvHelper(t)
+ positionals := []positionalArg{positionalArg{
+ InputName: "query",
+ Display: "",
+ Required: true,
+ }}
+ handler := shellOutToCLI(
+ func() (string, error) { return bin, nil },
+ []string{"areas", "search"},
+ map[string]bool{"args": true, "query": true},
+ map[string]bool{"args": true, "query": true},
+ positionals,
+ false,
+ nil,
+ )
+
+ result, err := handler(context.Background(), mcplib.CallToolRequest{Params: mcplib.CallToolParams{
+ Arguments: map[string]any{"args": "New York City"},
+ }})
+ if err != nil {
+ t.Fatalf("handler returned transport error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("handler returned tool error: %s", toolResultText(result))
+ }
+ got := decodeArgvResult(t, result)
+ want := []string{"areas", "search", "New York City"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("shellout argv = %#v, want %#v", got, want)
+ }
+}
+
+func TestShellOutStructuredPositionalPreservesWhitespace(t *testing.T) {
+ bin := writeArgvHelper(t)
+ positionals := []positionalArg{positionalArg{
+ InputName: "friend",
+ Display: "",
+ Required: true,
+ }}
+ handler := shellOutToCLI(
+ func() (string, error) { return bin, nil },
+ []string{"fairness", "nudge"},
+ map[string]bool{"args": true, "friend": true},
+ map[string]bool{"args": true, "friend": true},
+ positionals,
+ false,
+ nil,
+ )
+
+ result, err := handler(context.Background(), mcplib.CallToolRequest{Params: mcplib.CallToolParams{
+ Arguments: map[string]any{"friend": "Jane Q Public"},
+ }})
+ if err != nil {
+ t.Fatalf("handler returned transport error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("handler returned tool error: %s", toolResultText(result))
+ }
+ got := decodeArgvResult(t, result)
+ want := []string{"fairness", "nudge", "Jane Q Public"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("shellout argv = %#v, want %#v", got, want)
+ }
+}
+
+func TestShellOutRejectsUnknownStructuredParameters(t *testing.T) {
+ bin := writeArgvHelper(t)
+ positionals := []positionalArg{positionalArg{
+ InputName: "term",
+ Display: "",
+ Required: true,
+ }}
+ handler := shellOutToCLI(
+ func() (string, error) { return bin, nil },
+ []string{"search"},
+ map[string]bool{"args": true, "term": true},
+ map[string]bool{"args": true, "term": true},
+ positionals,
+ false,
+ nil,
+ )
+
+ result, err := handler(context.Background(), mcplib.CallToolRequest{Params: mcplib.CallToolParams{
+ Arguments: map[string]any{"query": "Eli Escobar"},
+ }})
+ if err != nil {
+ t.Fatalf("handler returned transport error: %v", err)
+ }
+ if !result.IsError {
+ t.Fatalf("handler accepted unknown parameter, result=%s", toolResultText(result))
+ }
+ if got := toolResultText(result); !strings.Contains(got, `unknown MCP parameter "query"`) {
+ t.Fatalf("tool error = %q, want unknown parameter message", got)
+ }
+}
+
+func TestRunCLICommandKeepsStdoutSeparateFromStderr(t *testing.T) {
+ bin := writeShelloutHelper(t, "success")
+ got, err := RunCLICommand(context.Background(), bin, []string{"alpha", "beta"})
+ if err != nil {
+ t.Fatalf("RunCLICommand success returned error: %v", err)
+ }
+ if !strings.Contains(got, `{"args":"alpha beta"}`) {
+ t.Fatalf("stdout result missing JSON payload: %q", got)
+ }
+ if strings.Contains(got, "telemetry") {
+ t.Fatalf("stderr telemetry leaked into stdout: %q", got)
+ }
+}
+
+func TestRunCLICommandReportsStderrOnFailure(t *testing.T) {
+ bin := writeShelloutHelper(t, "fail-stderr")
+ _, err := RunCLICommand(context.Background(), bin, nil)
+ if err == nil {
+ t.Fatal("RunCLICommand fail-stderr succeeded unexpectedly")
+ }
+ if !strings.Contains(err.Error(), "boom from stderr") {
+ t.Fatalf("error did not include stderr: %v", err)
+ }
+}
+
+func TestRunCLICommandFallsBackToStdoutOnFailureWithoutStderr(t *testing.T) {
+ bin := writeShelloutHelper(t, "fail-stdout")
+ _, err := RunCLICommand(context.Background(), bin, nil)
+ if err == nil {
+ t.Fatal("RunCLICommand fail-stdout succeeded unexpectedly")
+ }
+ if !strings.Contains(err.Error(), "stdout failure") {
+ t.Fatalf("error did not include stdout fallback: %v", err)
+ }
+ if !strings.Contains(err.Error(), "output: stdout failure") {
+ t.Fatalf("error did not label stdout fallback as output: %v", err)
+ }
+ if strings.Contains(err.Error(), "stderr: stdout failure") {
+ t.Fatalf("stdout fallback mislabeled as stderr: %v", err)
+ }
+}
+
+func writeShelloutHelper(t *testing.T, mode string) string {
+ t.Helper()
+ dir := t.TempDir()
+ if runtime.GOOS == "windows" {
+ path := filepath.Join(dir, "helper.bat")
+ var body string
+ switch mode {
+ case "success":
+ body = "@echo off\r\necho telemetry 1>&2\r\necho {\"args\":\"%*\"}\r\n"
+ case "fail-stderr":
+ body = "@echo off\r\necho boom from stderr 1>&2\r\nexit /b 7\r\n"
+ case "fail-stdout":
+ body = "@echo off\r\necho stdout failure\r\nexit /b 7\r\n"
+ default:
+ t.Fatalf("unknown mode %q", mode)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
+ t.Fatalf("write helper: %v", err)
+ }
+ return path
+ }
+ path := filepath.Join(dir, "helper.sh")
+ var body string
+ switch mode {
+ case "success":
+ body = "#!/bin/sh\necho telemetry >&2\nprintf '{\"args\":\"%s\"}\\n' \"$*\"\n"
+ case "fail-stderr":
+ body = "#!/bin/sh\necho boom from stderr >&2\nexit 7\n"
+ case "fail-stdout":
+ body = "#!/bin/sh\necho stdout failure\nexit 7\n"
+ default:
+ t.Fatalf("unknown mode %q", mode)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
+ t.Fatalf("write helper: %v", err)
+ }
+ return path
+}
+
+func writeArgvHelper(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ src := filepath.Join(dir, "argvhelper.go")
+ body := `package main
+
+import (
+ "encoding/json"
+ "os"
+)
+
+func main() {
+ _ = json.NewEncoder(os.Stdout).Encode(os.Args[1:])
+}
+`
+ if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
+ t.Fatalf("write argv helper source: %v", err)
+ }
+ bin := filepath.Join(dir, "argvhelper")
+ if runtime.GOOS == "windows" {
+ bin += ".exe"
+ }
+ cmd := exec.Command("go", "build", "-o", bin, src)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("build argv helper: %v\n%s", err, out)
+ }
+ return bin
+}
+
+func decodeArgvResult(t *testing.T, result *mcplib.CallToolResult) []string {
+ t.Helper()
+ var got []string
+ if err := json.Unmarshal([]byte(toolResultText(result)), &got); err != nil {
+ t.Fatalf("decode argv result: %v; text=%q", err, toolResultText(result))
+ }
+ return got
+}
+
+func toolResultText(result *mcplib.CallToolResult) string {
+ if result == nil || len(result.Content) == 0 {
+ return ""
+ }
+ text, ok := result.Content[0].(mcplib.TextContent)
+ if !ok {
+ return ""
+ }
+ return text.Text
+}
diff --git a/library/productivity/human-goat/internal/mcp/cobratree/typemap.go b/library/productivity/human-goat/internal/mcp/cobratree/typemap.go
new file mode 100644
index 0000000000..e4de3d0e64
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/cobratree/typemap.go
@@ -0,0 +1,213 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+ "regexp"
+ "strings"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+var positionalPattern = regexp.MustCompile(`(?:^|\s)(?:<[^>]+>|\[[^\]]+\])`)
+var positionalTokenPattern = regexp.MustCompile(`(?:^|\s)(<[^>]+>|\[[^\]]+\])`)
+
+type positionalArg struct {
+ InputName string
+ Display string
+ Required bool
+}
+
+func toolOptionsForFlags(cmd *cobra.Command, blocked map[string]bool, positionals []positionalArg) []mcplib.ToolOption {
+ var opts []mcplib.ToolOption
+ seen := map[string]bool{}
+ addFlag := func(flag *pflag.Flag) {
+ if flag == nil || flag.Hidden || flag.Deprecated != "" {
+ return
+ }
+ if reservedStructuredArgs[flag.Name] {
+ return
+ }
+ if seen[flag.Name] {
+ return
+ }
+ seen[flag.Name] = true
+ opts = append(opts, toolOptionForFlag(flag))
+ }
+ cmd.NonInheritedFlags().VisitAll(addFlag)
+ cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) {
+ if blocked[flag.Name] {
+ return
+ }
+ addFlag(flag)
+ })
+ for _, positional := range positionals {
+ if seen[positional.InputName] {
+ continue
+ }
+ seen[positional.InputName] = true
+ opts = append(opts, toolOptionForPositional(positional))
+ }
+ return opts
+}
+
+func allowedStructuredArgsForCommand(cmd *cobra.Command, blocked map[string]bool, positionals []positionalArg, allowRawArgs bool) map[string]bool {
+ allowed := map[string]bool{}
+ if allowRawArgs {
+ allowed["args"] = true
+ }
+ seen := map[string]bool{}
+ addFlag := func(flag *pflag.Flag) {
+ if flag == nil || flag.Hidden || flag.Deprecated != "" {
+ return
+ }
+ if reservedStructuredArgs[flag.Name] || blocked[flag.Name] || seen[flag.Name] {
+ return
+ }
+ seen[flag.Name] = true
+ allowed[flag.Name] = true
+ }
+ if cmd != nil {
+ cmd.NonInheritedFlags().VisitAll(addFlag)
+ cmd.InheritedFlags().VisitAll(addFlag)
+ }
+ for _, positional := range positionals {
+ allowed[positional.InputName] = true
+ }
+ return allowed
+}
+
+func toolOptionForFlag(flag *pflag.Flag) mcplib.ToolOption {
+ propOpts := []mcplib.PropertyOption{mcplib.Description(flagDescription(flag))}
+ if isRequired(flag) {
+ propOpts = append(propOpts, mcplib.Required())
+ }
+ switch flag.Value.Type() {
+ case "bool":
+ return mcplib.WithBoolean(flag.Name, propOpts...)
+ case "int", "int8", "int16", "int32", "int64",
+ "uint", "uint8", "uint16", "uint32", "uint64",
+ "float32", "float64", "count":
+ return mcplib.WithNumber(flag.Name, propOpts...)
+ case "string", "stringSlice", "stringArray", "duration":
+ return mcplib.WithString(flag.Name, propOpts...)
+ default:
+ propOpts[0] = mcplib.Description(flagDescription(flag) + " (unknown Cobra flag type " + flag.Value.Type() + "; pass as a string)")
+ return mcplib.WithString(flag.Name, propOpts...)
+ }
+}
+
+func toolOptionForPositional(positional positionalArg) mcplib.ToolOption {
+ propOpts := []mcplib.PropertyOption{mcplib.Description("Positional argument " + positional.Display)}
+ if positional.Required {
+ propOpts = append(propOpts, mcplib.Required())
+ }
+ return mcplib.WithString(positional.InputName, propOpts...)
+}
+
+func flagDescription(flag *pflag.Flag) string {
+ usage := strings.TrimSpace(flag.Usage)
+ if usage == "" {
+ usage = "Value for --" + flag.Name
+ }
+ if flag.DefValue != "" && flag.DefValue != "[]" {
+ usage += " (default: " + flag.DefValue + ")"
+ }
+ return usage
+}
+
+func isRequired(flag *pflag.Flag) bool {
+ if flag == nil || flag.Annotations == nil {
+ return false
+ }
+ _, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]
+ return ok
+}
+
+func commandTakesArgs(cmd *cobra.Command) bool {
+ return positionalPattern.MatchString(cmd.Use)
+}
+
+func positionalArgsForCommand(cmd *cobra.Command, blocked map[string]bool) []positionalArg {
+ if cmd == nil {
+ return nil
+ }
+ flagNames := map[string]bool{}
+ cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) {
+ if flag != nil && !blocked[flag.Name] {
+ flagNames[flag.Name] = true
+ }
+ })
+ cmd.NonInheritedFlags().VisitAll(func(flag *pflag.Flag) {
+ if flag != nil {
+ flagNames[flag.Name] = true
+ }
+ })
+ var out []positionalArg
+ for _, match := range positionalTokenPattern.FindAllStringSubmatch(cmd.Use, -1) {
+ if len(match) < 2 {
+ continue
+ }
+ raw := strings.TrimSpace(match[1])
+ if strings.Contains(raw, "--") {
+ continue
+ }
+ required := strings.HasPrefix(raw, "<")
+ name := strings.Trim(raw, "<>[]")
+ name = strings.TrimSuffix(name, "...")
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ inputName := name
+ if reservedStructuredArgs[inputName] || flagNames[inputName] {
+ inputName = "positional-" + inputName
+ }
+ out = append(out, positionalArg{
+ InputName: inputName,
+ Display: raw,
+ Required: required,
+ })
+ }
+ return out
+}
+
+func blockedStructuredArgsForCommand(cmd *cobra.Command) map[string]bool {
+ blocked := map[string]bool{}
+ for name := range reservedStructuredArgs {
+ blocked[name] = true
+ }
+ if cmd == nil {
+ return blocked
+ }
+ localFlags := map[string]bool{}
+ cmd.NonInheritedFlags().VisitAll(func(flag *pflag.Flag) {
+ if flag == nil || flag.Hidden || flag.Deprecated != "" {
+ return
+ }
+ localFlags[flag.Name] = true
+ })
+ cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) {
+ if flag == nil || flag.Hidden || flag.Deprecated != "" {
+ return
+ }
+ if blockedRootFlags[flag.Name] && !localFlags[flag.Name] {
+ blocked[flag.Name] = true
+ }
+ })
+ return blocked
+}
+
+func cliFlagBlockedArgs(blocked map[string]bool, positionals []positionalArg) map[string]bool {
+ out := map[string]bool{}
+ for name, value := range blocked {
+ out[name] = value
+ }
+ for _, positional := range positionals {
+ out[positional.InputName] = true
+ }
+ return out
+}
diff --git a/library/productivity/human-goat/internal/mcp/cobratree/walker.go b/library/productivity/human-goat/internal/mcp/cobratree/walker.go
new file mode 100644
index 0000000000..49414fa5af
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/cobratree/walker.go
@@ -0,0 +1,80 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/spf13/cobra"
+)
+
+// RegisterAll walks root's user-facing Cobra commands and registers shell-out
+// MCP tools for commands that are not already covered by typed endpoint tools.
+func RegisterAll(s *server.MCPServer, root *cobra.Command, cliPath func() (string, error)) {
+ if root == nil {
+ return
+ }
+ walk(root, nil, func(cmd *cobra.Command, path []string) {
+ switch classify(cmd) {
+ case commandHidden:
+ return
+ case commandEndpoint, commandFramework:
+ return
+ }
+ if !cmd.Runnable() {
+ return
+ }
+
+ toolName := toolNameForPath(path)
+ if toolName == "" {
+ return
+ }
+ if s.GetTool(toolName) != nil {
+ return
+ }
+ blockedStructuredArgs := blockedStructuredArgsForCommand(cmd)
+ positionals := positionalArgsForCommand(cmd, blockedStructuredArgs)
+ blockedCLIArgs := cliFlagBlockedArgs(blockedStructuredArgs, positionals)
+ allowedStructuredArgs := allowedStructuredArgsForCommand(cmd, blockedStructuredArgs, positionals, commandTakesArgs(cmd))
+ options := []mcplib.ToolOption{mcplib.WithDescription(descriptionFor(cmd))}
+ options = append(options, toolOptionsForFlags(cmd, blockedStructuredArgs, positionals)...)
+ if commandTakesArgs(cmd) && len(positionals) == 0 {
+ options = append(options, mcplib.WithString("args", mcplib.Description("Additional positional arguments to append to the command. Raw flags are rejected; use structured flag parameters instead.")))
+ }
+ readOnly := isMCPReadOnly(cmd)
+ if readOnly {
+ options = append(options, mcplib.WithReadOnlyHintAnnotation(true), mcplib.WithDestructiveHintAnnotation(false))
+ }
+ if !readOnly && isMCPLocalWrite(cmd) {
+ // Local-write tier: the command's only writes land in the CLI's
+ // own local store, so the tool is neither destructive nor
+ // open-world; readOnlyHint stays unset because it does write.
+ options = append(options, mcplib.WithDestructiveHintAnnotation(false), mcplib.WithOpenWorldHintAnnotation(false))
+ }
+ s.AddTool(mcplib.NewTool(toolName, options...), shellOutToCLI(cliPath, path, blockedCLIArgs, allowedStructuredArgs, positionals, readOnly, positionalWriteSinkIndexes(cmd)))
+ })
+}
+
+func walk(cmd *cobra.Command, path []string, visit func(*cobra.Command, []string)) {
+ for _, child := range cmd.Commands() {
+ if child.Hidden || isMCPHidden(child) {
+ continue
+ }
+ childPath := append(append([]string{}, path...), child.Name())
+ visit(child, childPath)
+ if kind := classify(child); kind != commandHidden && kind != commandFramework {
+ walk(child, childPath, visit)
+ }
+ }
+}
+
+func descriptionFor(cmd *cobra.Command) string {
+ if cmd.Long != "" {
+ return cmd.Long
+ }
+ if cmd.Short != "" {
+ return cmd.Short
+ }
+ return "Run `" + cmd.CommandPath() + "` through the companion CLI binary."
+}
diff --git a/library/productivity/human-goat/internal/mcp/intents.go b/library/productivity/human-goat/internal/mcp/intents.go
new file mode 100644
index 0000000000..fc42aaf126
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/intents.go
@@ -0,0 +1,153 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package mcp — intent handlers.
+//
+// Intents are higher-level MCP tools that compose multiple endpoint calls
+// or expose curated recipe commands with useful flag defaults.
+// Rather than forcing an agent to stitch primitives like get_thread +
+// create_issue + link_attachment, a single create_issue_from_thread tool
+// accepts the top-level input, calls each endpoint in sequence, resolves
+// bindings between steps, and returns the final captured value.
+//
+// Each intent is emitted as a handler registered alongside (or in place of,
+// when MCP.EndpointTools == "hidden") the raw endpoint-mirror tools.
+
+package mcp
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/mcp/bound"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/mcp/cobratree"
+)
+
+// RegisterIntents adds generated intent tools to the MCP server.
+// This is called from RegisterTools when spec intents or recipe-lifted intents exist.
+func RegisterIntents(s *server.MCPServer) {
+ s.AddTool(
+ mcplib.NewTool("undo_a_booking_and_verify",
+ mcplib.WithDescription("Cancels, re-reads status, and reports cancelled plus whether it was inside the free window and any fee."),
+ mcplib.WithString("slug", mcplib.Required(), mcplib.Description("Override the recipe's positional slug value.")),
+ ),
+ handleUndoABookingAndVerify,
+ )
+ s.AddTool(
+ mcplib.NewTool("dispatch_a_remote_errand",
+ mcplib.WithDescription("Sends a phone-call task to Magic and returns a request id to track; the answer comes back in the conversation."),
+ mcplib.WithString("id", mcplib.Required(), mcplib.Description("Override the recipe's positional id value.")),
+ ),
+ handleDispatchARemoteErrand,
+ )
+}
+
+// handleUndoABookingAndVerify runs the undo_a_booking_and_verify recipe intent: Cancels, re-reads status, and reports cancelled plus whether it was inside the free window and any fee.
+func handleUndoABookingAndVerify(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ if recipeCLIPathErr != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("companion CLI binary not found: %v", recipeCLIPathErr)), nil
+ }
+
+ input := req.GetArguments()
+ args := []string{}
+ args = append(args, "cancel")
+ var missingSlug bool
+ args, missingSlug = appendRecipePositional(args, input["slug"], true)
+ if missingSlug {
+ return mcplib.NewToolResultError("slug is required"), nil
+ }
+ args = append(args, "--agent")
+
+ out, err := cobratree.RunCLICommand(ctx, recipeCLIPath, args)
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+ return mcplib.NewToolResultText(bound.Text(out)), nil
+}
+
+// handleDispatchARemoteErrand runs the dispatch_a_remote_errand recipe intent: Sends a phone-call task to Magic and returns a request id to track; the answer comes back in the conversation.
+func handleDispatchARemoteErrand(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ if recipeCLIPathErr != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("companion CLI binary not found: %v", recipeCLIPathErr)), nil
+ }
+
+ input := req.GetArguments()
+ args := []string{}
+ args = append(args, "call")
+ var missingId bool
+ args, missingId = appendRecipePositional(args, input["id"], true)
+ if missingId {
+ return mcplib.NewToolResultError("id is required"), nil
+ }
+ args = append(args, "when does the jewelry store open")
+
+ out, err := cobratree.RunCLICommand(ctx, recipeCLIPath, args)
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+ return mcplib.NewToolResultText(bound.Text(out)), nil
+}
+
+var (
+ recipeCLIPath string
+ recipeCLIPathErr error
+)
+
+func init() {
+ recipeCLIPath, recipeCLIPathErr = cobratree.SiblingCLIPath()
+}
+
+func appendRecipePositional(args []string, value any, required bool) ([]string, bool) {
+ selected := recipeValueString(value)
+ if selected == "" {
+ return args, required
+ }
+ return append(args, selected), false
+}
+
+func appendRecipeStringFlag(args []string, name string, value any, defaultValue string, required bool, useEquals bool) ([]string, bool) {
+ selected := defaultValue
+ if value != nil {
+ selected = recipeValueString(value)
+ }
+ if selected == "" {
+ return args, required
+ }
+ if useEquals {
+ return append(args, "--"+name+"="+selected), false
+ }
+ return append(args, "--"+name, selected), false
+}
+
+func appendRecipeBoolFlag(args []string, name string, value any, defaultValue bool) []string {
+ selected := defaultValue
+ if v, ok := value.(bool); ok {
+ selected = v
+ }
+ if selected {
+ return append(args, "--"+name)
+ }
+ return args
+}
+
+func recipeValueString(value any) string {
+ switch v := value.(type) {
+ case string:
+ return strings.TrimSpace(v)
+ case float64:
+ return formatMCPParamValue(v)
+ case bool:
+ if v {
+ return "true"
+ }
+ return "false"
+ default:
+ if value == nil {
+ return ""
+ }
+ return fmt.Sprintf("%v", value)
+ }
+}
diff --git a/library/productivity/human-goat/internal/mcp/recipe_intents_test.go b/library/productivity/human-goat/internal/mcp/recipe_intents_test.go
new file mode 100644
index 0000000000..1c0dd7744f
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/recipe_intents_test.go
@@ -0,0 +1,102 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package mcp
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+)
+
+func TestRecipeIntentHandlerBuildsRecipeArgs(t *testing.T) {
+ oldPath, oldErr := recipeCLIPath, recipeCLIPathErr
+ t.Cleanup(func() {
+ recipeCLIPath, recipeCLIPathErr = oldPath, oldErr
+ })
+ recipeCLIPath = writeRecipeIntentRecorder(t)
+ recipeCLIPathErr = nil
+
+ req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{Arguments: map[string]any{
+ "slug": "required-value",
+ }}}
+ result, err := handleUndoABookingAndVerify(context.Background(), req)
+ if err != nil {
+ t.Fatalf("handler returned transport error: %v", err)
+ }
+ got := strings.TrimSpace(recipeToolText(t, result))
+ want := strings.Join([]string{
+ "cancel",
+ "required-value",
+ "--agent",
+ }, " ")
+ if got != want {
+ t.Fatalf("handler output = %q, want %q", got, want)
+ }
+}
+
+func TestRecipeIntentHandlerOverridesParams(t *testing.T) {
+ oldPath, oldErr := recipeCLIPath, recipeCLIPathErr
+ t.Cleanup(func() {
+ recipeCLIPath, recipeCLIPathErr = oldPath, oldErr
+ })
+ recipeCLIPath = writeRecipeIntentRecorder(t)
+ recipeCLIPathErr = nil
+
+ req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{Arguments: map[string]any{
+ "slug": "required-value",
+ }}}
+ result, err := handleUndoABookingAndVerify(context.Background(), req)
+ if err != nil {
+ t.Fatalf("handler returned transport error: %v", err)
+ }
+ got := recipeToolText(t, result)
+ if !strings.Contains(got, "required-value") {
+ t.Fatalf("positional override for slug missing from %q", got)
+ }
+}
+
+func writeRecipeIntentRecorder(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ if runtime.GOOS == "windows" {
+ path := filepath.Join(dir, "recorder.bat")
+ if err := os.WriteFile(path, []byte("@echo off\r\necho %*\r\n"), 0o755); err != nil {
+ t.Fatalf("write recorder: %v", err)
+ }
+ return path
+ }
+ path := filepath.Join(dir, "recorder.sh")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\"\n"), 0o755); err != nil {
+ t.Fatalf("write recorder: %v", err)
+ }
+ return path
+}
+
+func recipeToolText(t *testing.T, result *mcplib.CallToolResult) string {
+ t.Helper()
+ if result == nil {
+ t.Fatal("nil result")
+ }
+ if result.IsError {
+ if len(result.Content) == 0 {
+ return ""
+ }
+ if text, ok := result.Content[0].(mcplib.TextContent); ok {
+ return text.Text
+ }
+ }
+ if len(result.Content) == 0 {
+ t.Fatal("empty result content")
+ }
+ text, ok := result.Content[0].(mcplib.TextContent)
+ if !ok {
+ t.Fatalf("result content = %T, want TextContent", result.Content[0])
+ }
+ return text.Text
+}
diff --git a/library/productivity/human-goat/internal/mcp/tools.go b/library/productivity/human-goat/internal/mcp/tools.go
new file mode 100644
index 0000000000..05c845b7ae
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/tools.go
@@ -0,0 +1,880 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package mcp
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cli"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/config"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/learn"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/mcp/bound"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/mcp/cobratree"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+const (
+ // MCP hosts can fan out tool calls faster than a human CLI session.
+ // Keep them on the same polite-client limiter path instead of disabling
+ // pacing with rate=0; users can still tune human CLI calls with --rate-limit.
+ defaultMCPRateLimit = 2
+)
+
+// RegisterTools registers all API operations as MCP tools.
+func RegisterTools(s *server.MCPServer) {
+ s.AddTool(
+ mcplib.NewTool("account_get",
+ mcplib.WithDescription("Get the authenticated account profile. Returns the Account."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/api/v3/account.json", true, false, nil, mcpPageConfig{}, []mcpParamBinding{}, []string{}),
+ )
+ s.AddTool(
+ mcplib.NewTool("categories_list",
+ mcplib.WithDescription("List task templates for the account metro (title, category_name, category_id, default_template_id, top_category). Returns array of Category."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/api/v3/web-client/metro_task_template.json", true, false, nil, mcpPageConfig{}, []mcpParamBinding{}, []string{}),
+ )
+ s.AddTool(
+ mcplib.NewTool("invoices_has_submitted",
+ mcplib.WithDescription("Whether the account has submitted invoices (payment-history presence flag)."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/api/v3/decline_invoice/has_submitted_invoices", true, false, nil, mcpPageConfig{}, []mcpParamBinding{}, []string{}),
+ )
+ s.AddTool(
+ mcplib.NewTool("system_bootstrap",
+ mcplib.WithDescription("Account bootstrap — metro (id, name, country), payment_method_types, stream_api_key. Used by doctor to resolve the account metro. Returns the Bootstrap."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/api/v3/web-client/bootstrap.json", true, false, nil, mcpPageConfig{}, []mcpParamBinding{}, []string{}),
+ )
+ s.AddTool(
+ mcplib.NewTool("system_dashboard_counts",
+ mcplib.WithDescription("Dashboard tab counts (open tasks, messages, etc.)."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/api/v3/web-client/dashboard/counts", true, false, nil, mcpPageConfig{}, []mcpParamBinding{}, []string{}),
+ )
+ s.AddTool(
+ mcplib.NewTool("taskers_favorites",
+ mcplib.WithDescription("List your favorited Taskers (poster=client, rabbit=tasker). Optional: page. Returns array of Tasker."),
+ mcplib.WithNumber("page", mcplib.Description("Page number for pagination")),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/api/v3/poster_rabbit_relationships/favorite", true, false, nil, mcpPageConfig{}, []mcpParamBinding{{PublicName: "page", WireName: "page", Location: "query"}}, []string{}),
+ )
+ s.AddTool(
+ mcplib.NewTool("taskers_past",
+ mcplib.WithDescription("List Taskers you have hired before. Optional: page. Returns array of Tasker."),
+ mcplib.WithNumber("page", mcplib.Description("Page number for pagination")),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/api/v3/poster_rabbit_relationships/past", true, false, nil, mcpPageConfig{}, []mcpParamBinding{{PublicName: "page", WireName: "page", Location: "query"}}, []string{}),
+ )
+ s.AddTool(
+ mcplib.NewTool("taskers_suggestions",
+ mcplib.WithDescription("Tasker suggestions for the account metro. Returns array of Tasker."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/api/v3/web-client/dashboard/tasker_suggestions", true, false, nil, mcpPageConfig{}, []mcpParamBinding{}, []string{}),
+ )
+ // Intent tools — higher-level compositions declared in the spec or lifted from recipes.
+ RegisterIntents(s)
+ // Search tool — faster than iterating list endpoints for finding specific items
+ s.AddTool(
+ mcplib.NewTool("search",
+ mcplib.WithDescription("Full-text search across all synced data. Faster than paginating list endpoints. Requires sync first."),
+ mcplib.WithString("query", mcplib.Required(), mcplib.Description("Search query (supports FTS5 syntax: AND, OR, NOT, quotes for phrases)")),
+ mcplib.WithNumber("limit", mcplib.Description("Max results (default 25)")),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ ),
+ handleSearch,
+ )
+ // SQL tool — ad-hoc analysis on synced data without API calls
+ s.AddTool(
+ mcplib.NewTool("sql",
+ mcplib.WithDescription("Run read-only SQL against local database. Use for ad-hoc analysis, aggregations, and joins across synced resources. Requires sync first."),
+ mcplib.WithString("query", mcplib.Required(), mcplib.Description("SQL query (SELECT or WITH...SELECT). Synced records live in resources(resource_type, id, data); filter by resource_type and use json_extract on data, e.g. SELECT json_extract(data,'$.name') FROM resources WHERE resource_type='account'.")),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ ),
+ handleSQL,
+ )
+
+ // Context tool — front-loaded domain knowledge for agents.
+ // Call this first to understand the API taxonomy, query patterns, and capabilities.
+ s.AddTool(
+ mcplib.NewTool("context",
+ mcplib.WithDescription("Get API domain context: resource taxonomy, auth requirements, query tips, and unique capabilities. Call this first."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ ),
+ handleContext,
+ )
+
+ // Runtime Cobra-tree mirror — exposes every user-facing command that is
+ // not already covered by a typed endpoint or framework MCP tool.
+ cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
+}
+
+type mcpParamBinding struct {
+ PublicName string
+ WireName string
+ Location string
+}
+
+type mcpPageConfig struct {
+ CursorParam string
+ NextCursorPath string
+}
+
+func formatMCPParamValue(v any) string {
+ switch tv := v.(type) {
+ case string:
+ return tv
+ case bool:
+ return strconv.FormatBool(tv)
+ case float64:
+ if math.IsNaN(tv) || math.IsInf(tv, 0) {
+ return strconv.FormatFloat(tv, 'f', -1, 64)
+ }
+ if math.Trunc(tv) == tv && math.Abs(tv) < 1e15 {
+ return strconv.FormatInt(int64(tv), 10)
+ }
+ return strconv.FormatFloat(tv, 'f', -1, 64)
+ case float32:
+ f := float64(tv)
+ if math.IsNaN(f) || math.IsInf(f, 0) {
+ return strconv.FormatFloat(f, 'f', -1, 32)
+ }
+ if math.Trunc(f) == f && math.Abs(f) < 1e15 {
+ return strconv.FormatInt(int64(f), 10)
+ }
+ return strconv.FormatFloat(f, 'f', -1, 32)
+ default:
+ // Composite values (a native []any / map[string]any from an array or
+ // object param) reach this path when bound to a query or path slot;
+ // JSON-encode them so the wire value is valid JSON rather than Go's
+ // "[a b c]" / "map[...]" rendering. Body params never come through
+ // here — they are stored natively in bodyArgs and marshalled there.
+ if b, err := json.Marshal(v); err == nil {
+ return string(b)
+ }
+ return fmt.Sprintf("%v", v)
+ }
+}
+
+// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
+func makeAPIHandler(method, pathTemplate string, readOnly bool, binaryResponse bool, headerOverrides map[string]string, pageConfig mcpPageConfig, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+ return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ c, err := newMCPClient()
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+
+ // mcp-go v0.47+ made CallToolParams.Arguments an `any` to support
+ // non-map payloads; GetArguments() returns the map[string]any shape
+ // we rely on here (or an empty map when the payload is something else).
+ args := req.GetArguments()
+
+ // positionalParams mixes real URL path params with CLI positional
+ // args that map to query params (e.g. `search ` -> ?query=);
+ // the placeholder check below disambiguates them at runtime.
+ path := pathTemplate
+ knownArgs := make(map[string]bool, len(bindings))
+ pathParams := make(map[string]bool, len(positionalParams))
+ params := make(map[string]string)
+ bodyArgs := make(map[string]any)
+ mcpCursor := ""
+ if pageConfig.CursorParam != "" {
+ knownArgs["cursor"] = true
+ if v, ok := args["cursor"]; ok {
+ s, ok := v.(string)
+ if !ok {
+ return mcplib.NewToolResultError("cursor must be an opaque string returned by a previous MCP response"), nil
+ }
+ mcpCursor = s
+ upstreamCursor, err := bound.UpstreamCursor(s)
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+ if upstreamCursor != "" {
+ params[pageConfig.CursorParam] = upstreamCursor
+ }
+ }
+ }
+ var headers map[string]string
+ if len(headerOverrides) > 0 {
+ headers = make(map[string]string, len(headerOverrides)+1)
+ for k, v := range headerOverrides {
+ headers[k] = v
+ }
+ }
+ if binaryResponse {
+ if headers == nil {
+ headers = map[string]string{}
+ }
+ headers[client.BinaryResponseHeader] = "true"
+ }
+ for _, binding := range bindings {
+ knownArgs[binding.PublicName] = true
+ v, ok := args[binding.PublicName]
+ if !ok {
+ continue
+ }
+ switch binding.Location {
+ case "path":
+ placeholder := "{" + binding.WireName + "}"
+ pathParams[binding.PublicName] = true
+ path = strings.Replace(path, placeholder, formatMCPParamValue(v), 1)
+ case "body":
+ bodyArgs[binding.WireName] = v
+ default:
+ params[binding.WireName] = formatMCPParamValue(v)
+ }
+ }
+ for _, p := range positionalParams {
+ placeholder := "{" + p + "}"
+ if !strings.Contains(pathTemplate, placeholder) {
+ continue
+ }
+ pathParams[p] = true
+ if v, ok := args[p]; ok {
+ path = strings.Replace(path, placeholder, formatMCPParamValue(v), 1)
+ }
+ }
+
+ for k, v := range args {
+ if pathParams[k] || knownArgs[k] {
+ continue
+ }
+ switch method {
+ case "POST", "PUT", "PATCH":
+ bodyArgs[k] = v
+ default:
+ params[k] = formatMCPParamValue(v)
+ }
+ }
+
+ var data json.RawMessage
+ switch method {
+ case "GET":
+ if len(headers) > 0 {
+ data, err = c.GetWithHeaders(ctx, path, params, headers)
+ break
+ }
+ data, err = c.Get(ctx, path, params)
+ case "POST":
+ if len(headers) > 0 {
+ if readOnly {
+ data, _, err = c.PostQueryWithParamsAndHeaders(ctx, path, params, bodyArgs, headers)
+ } else {
+ data, _, err = c.PostWithParamsAndHeaders(ctx, path, params, bodyArgs, headers)
+ }
+ break
+ }
+ if readOnly {
+ data, _, err = c.PostQueryWithParams(ctx, path, params, bodyArgs)
+ } else {
+ data, _, err = c.PostWithParams(ctx, path, params, bodyArgs)
+ }
+ case "PUT":
+ if len(headers) > 0 {
+ data, _, err = c.PutWithParamsAndHeaders(ctx, path, params, bodyArgs, headers)
+ break
+ }
+ data, _, err = c.PutWithParams(ctx, path, params, bodyArgs)
+ case "PATCH":
+ if len(headers) > 0 {
+ data, _, err = c.PatchWithParamsAndHeaders(ctx, path, params, bodyArgs, headers)
+ break
+ }
+ data, _, err = c.PatchWithParams(ctx, path, params, bodyArgs)
+ case "DELETE":
+ if len(headers) > 0 {
+ data, _, err = c.DeleteWithParamsAndHeaders(ctx, path, params, headers)
+ break
+ }
+ data, _, err = c.DeleteWithParams(ctx, path, params)
+ default:
+ return mcplib.NewToolResultError("unsupported method: " + method), nil
+ }
+
+ if err != nil {
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "HTTP 409"):
+ return mcplib.NewToolResultText("already exists (no-op)"), nil
+ case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+ return mcplib.NewToolResultError("authentication error: " + cliutil.SanitizeErrorBody(msg) +
+ "\nhint: the API rejected the request — this usually means auth is missing or invalid." +
+ "\n Run 'human-goat-pp-cli auth login --chrome' to refresh browser-session credentials." +
+ "\n Run 'human-goat-pp-cli doctor' to check auth status."), nil
+ case strings.Contains(msg, "HTTP 401"):
+ return mcplib.NewToolResultError("authentication failed: " + cliutil.SanitizeErrorBody(msg) +
+ "\nhint: check your API credentials." +
+ "\n Run 'human-goat-pp-cli auth login --chrome' to refresh browser-session credentials." +
+ "\n Run 'human-goat-pp-cli doctor' to check auth status."), nil
+ case strings.Contains(msg, "HTTP 403"):
+ return mcplib.NewToolResultError("permission denied: " + cliutil.SanitizeErrorBody(msg) +
+ "\nhint: your credentials are valid but lack access to this resource. Check that they have the required permissions and match the API's expected auth scheme." +
+ "\n Run 'human-goat-pp-cli auth login --chrome' to refresh browser-session credentials." +
+ "\n Run 'human-goat-pp-cli doctor' to check auth status."), nil
+ case strings.Contains(msg, "HTTP 404"):
+ if method == "DELETE" {
+ return mcplib.NewToolResultText("already deleted (no-op)"), nil
+ }
+ return mcplib.NewToolResultError("not found: " + msg), nil
+ case strings.Contains(msg, "HTTP 429"):
+ return mcplib.NewToolResultError("rate limited: " + msg), nil
+ default:
+ return mcplib.NewToolResultError(msg), nil
+ }
+ }
+
+ if binaryResponse {
+ encoded := base64.StdEncoding.EncodeToString(data)
+ out, err := json.Marshal(map[string]any{
+ "content_encoding": "base64",
+ "data_base64": encoded,
+ "byte_count": len(data),
+ })
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("encoding binary result: %v", err)), nil
+ }
+ if len(out) > bound.MaxBytes {
+ return mcplib.NewToolResultError(fmt.Sprintf("binary response is too large for MCP text output: %d response bytes encode to %d base64 bytes and %d MCP result bytes, exceeding the %d byte budget. Use the companion CLI command with --output to save the payload locally.", len(data), len(encoded), len(out), bound.MaxBytes)), nil
+ }
+ return mcplib.NewToolResultText(string(out)), nil
+ }
+ if pageConfig.CursorParam != "" {
+ return mcpToolPageResultText(method, data, pageConfig, mcpCursor), nil
+ }
+ return mcpToolResultText(method, data), nil
+ }
+}
+
+func mcpToolResultText(method string, data json.RawMessage) *mcplib.CallToolResult {
+ return mcplib.NewToolResultText(bound.EndpointResponse(method, data))
+}
+
+func mcpToolPageResultText(method string, data json.RawMessage, pageConfig mcpPageConfig, cursor string) *mcplib.CallToolResult {
+ return mcplib.NewToolResultText(bound.EndpointPageResponse(method, data, bound.PageOptions{
+ Cursor: cursor,
+ CursorParam: pageConfig.CursorParam,
+ NextCursorPath: pageConfig.NextCursorPath,
+ }))
+}
+
+func newMCPClient() (*client.Client, error) {
+ cfg, err := newMCPConfig()
+ if err != nil {
+ return nil, err
+ }
+ return newMCPClientFromConfig(cfg), nil
+}
+
+func newMCPConfig() (*config.Config, error) {
+ cfg, err := config.Load("")
+ if err != nil {
+ return nil, fmt.Errorf("loading config: %w", err)
+ }
+ return cfg, nil
+}
+
+func newMCPClientFromConfig(cfg *config.Config) *client.Client {
+ c := client.New(cfg, 60*time.Second, defaultMCPRateLimit)
+ // Agents calling through MCP need fresh data every call. The on-disk
+ // response cache survives across MCP server invocations, so a
+ // DELETE/PATCH followed by a GET would otherwise return the
+ // pre-mutation snapshot for up to the cache TTL. The interactive CLI
+ // constructs its own client and is unaffected.
+ c.NoCache = true
+ return c
+}
+
+func mcpDBPath() (string, error) {
+ dir, err := cliutil.DataDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "data.db"), nil
+}
+
+type mcpStoreStatusKind string
+
+const (
+ mcpStoreStatusEmpty mcpStoreStatusKind = "empty"
+ mcpStoreStatusReady mcpStoreStatusKind = "ready"
+)
+
+func openMCPReadOnlyStore(path string) (*store.Store, *mcplib.CallToolResult) {
+ if _, err := os.Stat(path); err != nil {
+ if os.IsNotExist(err) {
+ return nil, mcplib.NewToolResultError(mcpMissingStoreMessage(path))
+ }
+ return nil, mcplib.NewToolResultError(fmt.Sprintf("checking local data store %s: %v", path, err))
+ }
+ db, err := store.OpenReadOnly(path)
+ if err != nil {
+ return nil, mcplib.NewToolResultError(fmt.Sprintf("opening local data store %s: %v. Run human-goat-pp-cli sync to refresh the store, or use live endpoint MCP tools for unsynced data.", path, err))
+ }
+ return db, nil
+}
+
+func mcpMissingStoreMessage(path string) string {
+ return fmt.Sprintf("No local data store found at %s. Run human-goat-pp-cli sync before using MCP search/sql, or use live endpoint MCP tools for unsynced data.", path)
+}
+
+func mcpStoreStatus(db *store.Store) (mcpStoreStatusKind, error) {
+ status, err := db.Status()
+ if err != nil {
+ return "", err
+ }
+ if len(status) == 0 {
+ return mcpStoreStatusEmpty, nil
+ }
+ return mcpStoreStatusReady, nil
+}
+
+func mcpEmptyStoreNextStep() string {
+ return "Run human-goat-pp-cli sync to populate the local SQLite store before using MCP search/sql."
+}
+
+func handleSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ args := req.GetArguments()
+ query, ok := args["query"].(string)
+ if !ok || query == "" {
+ return mcplib.NewToolResultError("query is required"), nil
+ }
+
+ limit := 25
+ if v, ok := args["limit"].(float64); ok && v > 0 {
+ limit = int(v)
+ }
+
+ path, err := mcpDBPath()
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("resolving database: %v", err)), nil
+ }
+ db, toolErr := openMCPReadOnlyStore(path)
+ if toolErr != nil {
+ return toolErr, nil
+ }
+ defer db.Close()
+
+ results, err := db.Search(query, limit)
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("search failed: %v", err)), nil
+ }
+ storeStatus, err := mcpStoreStatus(db)
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("reading store status: %v", err)), nil
+ }
+
+ return toolResultJSON(mcpSearchEnvelope(results, storeStatus))
+}
+
+func mcpSearchEnvelope(results []json.RawMessage, storeStatus mcpStoreStatusKind) map[string]any {
+ if results == nil {
+ results = []json.RawMessage{}
+ }
+ out := map[string]any{
+ "count": len(results),
+ "results": results,
+ "store_status": storeStatus,
+ "resumable": false,
+ }
+ if len(results) == 0 {
+ if storeStatus == mcpStoreStatusEmpty {
+ out["next_step"] = mcpEmptyStoreNextStep()
+ } else {
+ out["next_step"] = "No local search matches. Try a broader query, a lower-specificity FTS expression, or sync again if data may be stale."
+ }
+ }
+ return out
+}
+
+// validateReadOnlyQuery gates the MCP sql tool. The agent contract advertised
+// to the host is ReadOnlyHintAnnotation(true); a false annotation on a
+// mutating tool lets MCP hosts auto-approve writes and is treated as a real
+// bug per the project's agent-native security model.
+//
+// The gate rejects multi-statement input, then applies an allowlist (SELECT or
+// WITH only) AFTER stripping the leading whitespace, line comments, block
+// comments, and semicolons that SQLite itself ignores before parsing. A naive
+// HasPrefix check on a keyword blocklist is bypassable by prefixing the
+// dangerous statement with "/* x */" or "-- x\n"; a naive leading-keyword
+// allowlist is bypassable by appending "; ATTACH DATABASE ...". Combined with
+// the empirical fact that modernc.org/sqlite's mode=ro does NOT block VACUUM
+// INTO (writes a snapshot to a new file) or ATTACH DATABASE (opens a separate
+// writable handle), either bypass produces silent exfiltration to an
+// attacker-chosen path.
+//
+// SELECT and WITH are the only allowed leading keywords. WITH supports
+// SELECT-form CTEs; CTE-wrapped writes ("WITH x AS (...) INSERT ...") are
+// caught by OpenReadOnly's mode=ro one layer down. PRAGMA, ATTACH, VACUUM,
+// and every other DDL/DML keyword fail at this gate before reaching SQLite.
+func validateReadOnlyQuery(query string) error {
+ stripped := stripLeadingSQLNoise(query)
+ if hasTrailingSQLStatement(stripped) {
+ return fmt.Errorf("only a single SELECT or WITH statement is allowed")
+ }
+ upper := strings.ToUpper(stripped)
+ if !strings.HasPrefix(upper, "SELECT") && !strings.HasPrefix(upper, "WITH") {
+ return fmt.Errorf("only SELECT queries are allowed")
+ }
+ return nil
+}
+
+// stripLeadingSQLNoise removes leading whitespace, SQL line comments
+// (-- to end of line), block comments (/* ... */), and statement
+// separators (;) from query. SQLite skips these before parsing the first
+// keyword, so a security gate that does not strip them mismatches what the
+// driver actually executes.
+func stripLeadingSQLNoise(query string) string {
+ for {
+ query = strings.TrimLeft(query, " \t\r\n;")
+ switch {
+ case strings.HasPrefix(query, "--"):
+ if idx := strings.IndexByte(query, '\n'); idx >= 0 {
+ query = query[idx+1:]
+ continue
+ }
+ return ""
+ case strings.HasPrefix(query, "/*"):
+ if idx := strings.Index(query[2:], "*/"); idx >= 0 {
+ query = query[2+idx+2:]
+ continue
+ }
+ return ""
+ default:
+ return query
+ }
+ }
+}
+
+// hasTrailingSQLStatement reports whether query contains a statement
+// terminator followed by more executable SQL. A trailing semicolon is allowed;
+// a second statement is not. Semicolons inside string literals, quoted
+// identifiers, bracket identifiers, and comments are ignored to match SQLite's
+// parser shape closely enough for this security gate.
+func hasTrailingSQLStatement(query string) bool {
+ inSingle := false
+ inDouble := false
+ inBacktick := false
+ inBracket := false
+ inLineComment := false
+ inBlockComment := false
+
+ for i := 0; i < len(query); i++ {
+ ch := query[i]
+ next := byte(0)
+ if i+1 < len(query) {
+ next = query[i+1]
+ }
+
+ switch {
+ case inLineComment:
+ if ch == '\n' {
+ inLineComment = false
+ }
+ continue
+ case inBlockComment:
+ if ch == '*' && next == '/' {
+ inBlockComment = false
+ i++
+ }
+ continue
+ case inSingle:
+ if ch == '\'' {
+ if next == '\'' {
+ i++
+ continue
+ }
+ inSingle = false
+ }
+ continue
+ case inDouble:
+ if ch == '"' {
+ if next == '"' {
+ i++
+ continue
+ }
+ inDouble = false
+ }
+ continue
+ case inBacktick:
+ if ch == '`' {
+ if next == '`' {
+ i++
+ continue
+ }
+ inBacktick = false
+ }
+ continue
+ case inBracket:
+ if ch == ']' {
+ inBracket = false
+ }
+ continue
+ }
+
+ switch {
+ case ch == '-' && next == '-':
+ inLineComment = true
+ i++
+ case ch == '/' && next == '*':
+ inBlockComment = true
+ i++
+ case ch == '\'':
+ inSingle = true
+ case ch == '"':
+ inDouble = true
+ case ch == '`':
+ inBacktick = true
+ case ch == '[':
+ inBracket = true
+ case ch == ';':
+ if stripLeadingSQLNoise(query[i+1:]) != "" {
+ return true
+ }
+ return false
+ }
+ }
+ return false
+}
+
+func handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ args := req.GetArguments()
+ query, ok := args["query"].(string)
+ if !ok || query == "" {
+ return mcplib.NewToolResultError("query is required"), nil
+ }
+
+ if err := validateReadOnlyQuery(query); err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+
+ path, err := mcpDBPath()
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("resolving database: %v", err)), nil
+ }
+ db, toolErr := openMCPReadOnlyStore(path)
+ if toolErr != nil {
+ return toolErr, nil
+ }
+ defer db.Close()
+
+ rows, err := db.DB().QueryContext(ctx, query)
+ if err != nil {
+ return mcplib.NewToolResultError(mcpSQLQueryError(err)), nil
+ }
+ defer rows.Close()
+
+ cols, err := rows.Columns()
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("reading columns: %v", err)), nil
+ }
+ var results []map[string]any
+ for rows.Next() {
+ values := make([]any, len(cols))
+ ptrs := make([]any, len(cols))
+ for i := range values {
+ ptrs[i] = &values[i]
+ }
+ if err := rows.Scan(ptrs...); err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("scanning row: %v", err)), nil
+ }
+ row := make(map[string]any)
+ for i, col := range cols {
+ row[col] = values[i]
+ }
+ results = append(results, row)
+ }
+ // rows.Next() stops on a mid-iteration error without failing the loop, so
+ // skipping rows.Err() would return a truncated result set as success.
+ if err := rows.Err(); err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("reading rows: %v", err)), nil
+ }
+ storeStatus, err := mcpStoreStatus(db)
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("reading store status: %v", err)), nil
+ }
+
+ return toolResultJSON(mcpSQLEnvelope(results, cols, storeStatus))
+}
+
+func mcpSQLEnvelope(rows []map[string]any, columns []string, storeStatus mcpStoreStatusKind) map[string]any {
+ if rows == nil {
+ rows = []map[string]any{}
+ }
+ out := map[string]any{
+ "count": len(rows),
+ "columns": columns,
+ "rows": rows,
+ "store_status": storeStatus,
+ "resumable": false,
+ }
+ if len(rows) == 0 {
+ if storeStatus == mcpStoreStatusEmpty {
+ out["next_step"] = mcpEmptyStoreNextStep()
+ } else {
+ out["next_step"] = "The read-only SQL query returned no rows. Check resource_type filters, json_extract paths, or run sync again if data may be stale."
+ }
+ }
+ return out
+}
+
+func mcpSQLQueryError(err error) string {
+ msg := err.Error()
+ if strings.Contains(strings.ToLower(msg), "no such table") {
+ return fmt.Sprintf("query failed: %v. Synced records live in resources(resource_type, id, data), not one SQL table per resource. Filter by resource_type, for example resource_type='account', and read JSON fields with json_extract(data,'$.field').", err)
+ }
+ return fmt.Sprintf("query failed: %v", err)
+}
+
+// toolResultJSON renders v as the indented JSON body of an MCP text result,
+// surfacing a marshal failure as a tool error instead of empty content.
+func toolResultJSON(v any) (*mcplib.CallToolResult, error) {
+ text, err := bound.JSON(v)
+ if err != nil {
+ return mcplib.NewToolResultError(fmt.Sprintf("encoding result: %v", err)), nil
+ }
+ return mcplib.NewToolResultText(text), nil
+}
+
+func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ paths := map[string]string{}
+ if dir, err := cliutil.ConfigDir(); err == nil {
+ paths["config_dir"] = dir
+ }
+ if dir, err := cliutil.DataDir(); err == nil {
+ paths["data_dir"] = dir
+ }
+ if dir, err := cliutil.StateDir(); err == nil {
+ paths["state_dir"] = dir
+ }
+ if dir, err := cliutil.CacheDir(); err == nil {
+ paths["cache_dir"] = dir
+ }
+ ctx := map[string]any{
+ "api": "human-goat",
+ "description": "Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary.",
+ "archetype": "payments",
+ "tool_count": 8,
+ "paths": paths,
+ // tool_surface tells agents which surface a capability lives on.
+ "tool_surface": "MCP exposes typed endpoint tools plus a runtime mirror of user-facing CLI commands. Endpoint tools keep typed schemas; command-mirror tools shell out to the companion human-goat-pp-cli binary.",
+ // learn_protocol is generated from the single shared source of
+ // truth (the exported constant internal/learn.RecallFirstProtocol)
+ // also consumed by the CLI agent-context command, so the MCP and
+ // CLI agent surfaces cannot drift.
+ "learn_protocol": learn.RecallFirstProtocol,
+ "auth": map[string]any{
+ "type": "cookie",
+ },
+ "resources": []map[string]any{
+ {
+ "name": "account",
+ "description": "The logged-in TaskRabbit client account profile",
+ "endpoints": []string{"get"},
+ },
+ {
+ "name": "categories",
+ "description": "TaskRabbit task categories and templates for the account metro",
+ "endpoints": []string{"list"},
+ "syncable": true,
+ },
+ {
+ "name": "invoices",
+ "description": "Invoice and payment-history flags",
+ "endpoints": []string{"has_submitted"},
+ },
+ {
+ "name": "system",
+ "description": "TaskRabbit account bootstrap, metro, and dashboard reachability",
+ "endpoints": []string{"bootstrap", "dashboard_counts"},
+ },
+ {
+ "name": "taskers",
+ "description": "Favorite, past, and suggested TaskRabbit Taskers",
+ "endpoints": []string{"favorites", "past", "suggestions"},
+ "syncable": true,
+ },
+ },
+ "query_tips": []string{
+ "Pagination uses cursor-based paging. Pass after parameter for subsequent pages.",
+ "Control page size with the limit parameter (default 100).",
+ "Use the sql tool for ad-hoc analysis on synced data. Run sync first to populate the local database.",
+ "Use the search tool for full-text search across all synced resources. Faster than iterating list endpoints.",
+ "Prefer sql/search over repeated API calls when the data is already synced.",
+ },
+ // Command-mirror capabilities are exposed through MCP by shelling out
+ // to the companion CLI binary.
+ "command_mirror_capabilities": []map[string]string{
+ {"name": "Autonomous soup-to-nuts hire", "command": "hire", "description": "Say the job and the date; goat searches, ranks by review quality and honest all-in price, and checks out against the card on file with no prompt.", "rationale": "Only a client-side agent can walk the whole TaskRabbit funnel and commit checkout unattended; the app forces a human through every step.", "via": "mcp-command-mirror"},
+ {"name": "Verified cancel (the safety gate)", "command": "cancel", "description": "Cancels a booking and confirms it landed by re-reading status, reporting whether it was inside the free window and any fee.", "rationale": "Requires reading back booking state after the mutation; a fire-and-forget cancel would defeat the whole hands-off safety model.", "via": "mcp-command-mirror"},
+ {"name": "Spend cap", "command": "hire", "description": "Refuses to check out when the computed all-in total exceeds a configurable ceiling, printing the total and the cap.", "rationale": "Bounds blast radius on unattended checkout without adding a per-hire human prompt.", "via": "mcp-command-mirror"},
+ {"name": "All-in price", "command": "best", "description": "Folds TaskRabbit's hidden service (~15%) and trust-and-support (5-15%) fees into the displayed hourly rate, honoring the CA/MA service-fee-only rule.", "rationale": "The app hides fees until checkout; the CLI computes the honest effective rate client-side and makes it the default price everywhere.", "via": "mcp-command-mirror"},
+ {"name": "Cross-source dispatch", "command": "dispatch", "description": "Routes a plain-language task to Magic (remote-doable) or TaskRabbit (in-person) by task shape, with a --via override.", "rationale": "Only a unified binary over both human networks can pick the right one from the task alone.", "via": "mcp-command-mirror"},
+ {"name": "Cross-source spend analytics", "command": "spend", "description": "SQL over local booking, invoice, and Magic-task history by category, tasker, source, or month, using true effective all-in $/hr for TaskRabbit.", "rationale": "Requires a local store joining bookings, invoices, and remote tasks that only exist together offline.", "via": "mcp-command-mirror"},
+ {"name": "Availability watch", "command": "watch", "description": "Polls recommendations for a category and date (optionally a favorite or a rate ceiling) and alerts when a match opens.", "rationale": "The app has no watch primitive; polling the recommendations endpoint from the store enables it.", "via": "mcp-command-mirror"},
+ {"name": "Unified in-flight inbox", "command": "status", "description": "One list of every in-flight task across TaskRabbit bookings and Magic requests, joined on the common task model and sorted by state.", "rationale": "Only a unified store joining two human networks can show all pending work in one place; each backend only knows its own.", "via": "mcp-command-mirror"},
+ },
+ "playbook": []map[string]string{
+ {"topic": "Autonomous soup-to-nuts hire", "insight": "Only a client-side agent can walk the whole TaskRabbit funnel and commit checkout unattended; the app forces a human through every step."},
+ {"topic": "Verified cancel (the safety gate)", "insight": "Requires reading back booking state after the mutation; a fire-and-forget cancel would defeat the whole hands-off safety model."},
+ {"topic": "Spend cap", "insight": "Bounds blast radius on unattended checkout without adding a per-hire human prompt."},
+ {"topic": "All-in price", "insight": "The app hides fees until checkout; the CLI computes the honest effective rate client-side and makes it the default price everywhere."},
+ {"topic": "Side-by-side compare", "insight": "The app shows one Tasker at a time; the local store makes a ranked head-to-head trivial."},
+ {"topic": "Cross-source dispatch", "insight": "Only a unified binary over both human networks can pick the right one from the task alone."},
+ {"topic": "Cross-source spend analytics", "insight": "Requires a local store joining bookings, invoices, and remote tasks that only exist together offline."},
+ {"topic": "Availability watch", "insight": "The app has no watch primitive; polling the recommendations endpoint from the store enables it."},
+ {"topic": "Rebook a favorite", "insight": "The app buries rehire inside completed tasks; the local favorites/past store makes it a verb."},
+ {"topic": "Financial data", "insight": "Always use read-only operations for financial queries. Never use create/update tools for payment data without explicit user confirmation."},
+ {"topic": "Reconciliation", "insight": "For reconciliation tasks, sync first then use sql for cross-referencing. API pagination over financial records is slow and rate-limited."},
+ },
+ }
+ return toolResultJSON(ctx)
+}
+
+// RegisterNovelFeatureTools is kept as a compatibility no-op for older MCP
+// mains. New generated mains call RegisterTools only; RegisterTools now
+// includes the runtime Cobra-tree mirror.
+func RegisterNovelFeatureTools(s *server.MCPServer) {
+ _ = s
+}
diff --git a/library/productivity/human-goat/internal/mcp/tools_test.go b/library/productivity/human-goat/internal/mcp/tools_test.go
new file mode 100644
index 0000000000..a8f9ec06c9
--- /dev/null
+++ b/library/productivity/human-goat/internal/mcp/tools_test.go
@@ -0,0 +1,610 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/mcp/bound"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+func TestMCPPathResolutionMatchesCLIResolverWithHomeEnv(t *testing.T) {
+ resetMCPPathEnv(t)
+ root := filepath.Join(t.TempDir(), "shared-home")
+ t.Setenv("HUMAN_GOAT_HOME", root)
+
+ cfg, err := newMCPConfig()
+ if err != nil {
+ t.Fatalf("newMCPConfig() error = %v", err)
+ }
+ cliConfigDir, err := cliutil.ConfigDir()
+ if err != nil {
+ t.Fatalf("cliutil.ConfigDir() error = %v", err)
+ }
+ if want := filepath.Join(cliConfigDir, "config.toml"); cfg.Path != want {
+ t.Fatalf("MCP config path = %q, want CLI resolver path %q", cfg.Path, want)
+ }
+
+ gotDB, err := mcpDBPath()
+ if err != nil {
+ t.Fatalf("mcpDBPath() error = %v", err)
+ }
+ cliDataDir, err := cliutil.DataDir()
+ if err != nil {
+ t.Fatalf("cliutil.DataDir() error = %v", err)
+ }
+ if want := filepath.Join(cliDataDir, "data.db"); gotDB != want {
+ t.Fatalf("MCP db path = %q, want CLI resolver path %q", gotDB, want)
+ }
+}
+
+func TestMCPPathResolutionMatchesCLIResolverWithPlatformDefaults(t *testing.T) {
+ home := resetMCPPathEnv(t)
+
+ cfg, err := newMCPConfig()
+ if err != nil {
+ t.Fatalf("newMCPConfig() error = %v", err)
+ }
+ if want := filepath.Join(home, ".config", "human-goat-pp-cli", "config.toml"); cfg.Path != want {
+ t.Fatalf("MCP config path = %q, want %q", cfg.Path, want)
+ }
+
+ gotDB, err := mcpDBPath()
+ if err != nil {
+ t.Fatalf("mcpDBPath() error = %v", err)
+ }
+ if want := filepath.Join(home, ".local", "share", "human-goat-pp-cli", "data.db"); gotDB != want {
+ t.Fatalf("MCP db path = %q, want %q", gotDB, want)
+ }
+}
+
+func resetMCPPathEnv(t *testing.T) string {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ for _, name := range []string{
+ "HUMAN_GOAT_CONFIG",
+ "HUMAN_GOAT_CONFIG_DIR",
+ "HUMAN_GOAT_DATA_DIR",
+ "HUMAN_GOAT_STATE_DIR",
+ "HUMAN_GOAT_CACHE_DIR",
+ "HUMAN_GOAT_HOME",
+ "XDG_CONFIG_HOME",
+ "XDG_DATA_HOME",
+ "XDG_STATE_HOME",
+ "XDG_CACHE_HOME",
+ } {
+ t.Setenv(name, "")
+ }
+ restore, err := cliutil.SetHomeOverride("")
+ if err != nil {
+ t.Fatalf("reset home override: %v", err)
+ }
+ t.Cleanup(restore)
+ return home
+}
+
+func TestMCPRegisterToolsPreservesTypedSpecialTools(t *testing.T) {
+ s := server.NewMCPServer("human-goat", "test")
+ RegisterTools(s)
+
+ tools := s.ListTools()
+ if len(tools) == 0 {
+ t.Fatal("RegisterTools registered no tools")
+ }
+ contextTool, ok := tools["context"]
+ if !ok {
+ t.Fatalf("typed context tool missing from registered tools: %#v", tools)
+ }
+ if !strings.Contains(contextTool.Tool.Description, "Get API domain context") {
+ t.Fatalf("context tool appears to have been overwritten by command mirror: %q", contextTool.Tool.Description)
+ }
+ searchTool, ok := tools["search"]
+ if !ok {
+ t.Fatalf("typed search tool missing from registered tools: %#v", tools)
+ }
+ if !strings.Contains(searchTool.Tool.Description, "Full-text search across all synced data") {
+ t.Fatalf("search tool appears to have been overwritten by command mirror: %q", searchTool.Tool.Description)
+ }
+ sqlTool, ok := tools["sql"]
+ if !ok {
+ t.Fatalf("typed sql tool missing from registered tools: %#v", tools)
+ }
+ if !strings.Contains(sqlTool.Tool.Description, "Run read-only SQL against local database") {
+ t.Fatalf("sql tool appears to have been overwritten by command mirror: %q", sqlTool.Tool.Description)
+ }
+}
+
+func TestMCPSearchMissingStoreIsActionable(t *testing.T) {
+ resetMCPPathEnv(t)
+
+ result, err := handleSearch(context.Background(), mcplib.CallToolRequest{Params: mcplib.CallToolParams{
+ Arguments: map[string]any{"query": "alpha"},
+ }})
+ if err != nil {
+ t.Fatalf("handleSearch returned transport error: %v", err)
+ }
+ if result == nil || !result.IsError {
+ t.Fatalf("handleSearch missing store IsError = %v, want true", result != nil && result.IsError)
+ }
+ text := mcpTextContent(t, result)
+ for _, want := range []string{"No local data store found", "data.db", "Run", "sync"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("missing-store error %q missing %q", text, want)
+ }
+ }
+}
+
+func TestMCPSearchEmptyStoreReturnsActionableEnvelope(t *testing.T) {
+ resetMCPPathEnv(t)
+ path, err := mcpDBPath()
+ if err != nil {
+ t.Fatalf("mcpDBPath() error = %v", err)
+ }
+ db, err := store.Open(path)
+ if err != nil {
+ t.Fatalf("creating empty store: %v", err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatalf("closing empty store: %v", err)
+ }
+
+ result, err := handleSearch(context.Background(), mcplib.CallToolRequest{Params: mcplib.CallToolParams{
+ Arguments: map[string]any{"query": "alpha"},
+ }})
+ if err != nil {
+ t.Fatalf("handleSearch returned transport error: %v", err)
+ }
+ if result == nil || result.IsError {
+ t.Fatalf("handleSearch empty store IsError = %v, want false", result != nil && result.IsError)
+ }
+ text := mcpTextContent(t, result)
+ if len(text) > bound.MaxBytes {
+ t.Fatalf("empty-store envelope length = %d, want <= %d", len(text), bound.MaxBytes)
+ }
+ var envelope struct {
+ Count int `json:"count"`
+ Results []json.RawMessage `json:"results"`
+ StoreStatus string `json:"store_status"`
+ Resumable bool `json:"resumable"`
+ NextStep string `json:"next_step"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("empty-store result must be valid JSON: %v\n%s", err, text)
+ }
+ if envelope.Count != 0 || len(envelope.Results) != 0 {
+ t.Fatalf("empty-store envelope returned rows: %s", text)
+ }
+ if envelope.StoreStatus != "empty" {
+ t.Fatalf("store_status = %q, want empty in %s", envelope.StoreStatus, text)
+ }
+ if envelope.Resumable {
+ t.Fatalf("empty-store envelope should not claim cursor support: %s", text)
+ }
+ if !strings.Contains(envelope.NextStep, "sync") {
+ t.Fatalf("empty-store next_step should mention sync: %s", text)
+ }
+}
+
+func TestMCPSQLMissingStoreIsActionable(t *testing.T) {
+ resetMCPPathEnv(t)
+
+ result, err := handleSQL(context.Background(), mcplib.CallToolRequest{Params: mcplib.CallToolParams{
+ Arguments: map[string]any{"query": "SELECT 1"},
+ }})
+ if err != nil {
+ t.Fatalf("handleSQL returned transport error: %v", err)
+ }
+ if result == nil || !result.IsError {
+ t.Fatalf("handleSQL missing store IsError = %v, want true", result != nil && result.IsError)
+ }
+ text := mcpTextContent(t, result)
+ for _, want := range []string{"No local data store found", "data.db", "Run", "sync"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("missing-store error %q missing %q", text, want)
+ }
+ }
+}
+
+func TestMCPSQLEmptyStoreReturnsActionableEnvelope(t *testing.T) {
+ resetMCPPathEnv(t)
+ path, err := mcpDBPath()
+ if err != nil {
+ t.Fatalf("mcpDBPath() error = %v", err)
+ }
+ db, err := store.Open(path)
+ if err != nil {
+ t.Fatalf("creating empty store: %v", err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatalf("closing empty store: %v", err)
+ }
+
+ result, err := handleSQL(context.Background(), mcplib.CallToolRequest{Params: mcplib.CallToolParams{
+ Arguments: map[string]any{"query": "SELECT * FROM resources"},
+ }})
+ if err != nil {
+ t.Fatalf("handleSQL returned transport error: %v", err)
+ }
+ if result == nil || result.IsError {
+ t.Fatalf("handleSQL empty store IsError = %v, want false", result != nil && result.IsError)
+ }
+ text := mcpTextContent(t, result)
+ if len(text) > bound.MaxBytes {
+ t.Fatalf("empty-store envelope length = %d, want <= %d", len(text), bound.MaxBytes)
+ }
+ var envelope struct {
+ Count int `json:"count"`
+ Rows []map[string]any `json:"rows"`
+ Columns []string `json:"columns"`
+ StoreStatus string `json:"store_status"`
+ Resumable bool `json:"resumable"`
+ NextStep string `json:"next_step"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("empty-store SQL result must be valid JSON: %v\n%s", err, text)
+ }
+ if envelope.Count != 0 || len(envelope.Rows) != 0 {
+ t.Fatalf("empty-store SQL envelope returned rows: %s", text)
+ }
+ if len(envelope.Columns) == 0 {
+ t.Fatalf("empty-store SQL envelope should preserve columns: %s", text)
+ }
+ if envelope.StoreStatus != "empty" {
+ t.Fatalf("store_status = %q, want empty in %s", envelope.StoreStatus, text)
+ }
+ if envelope.Resumable {
+ t.Fatalf("empty-store SQL envelope should not claim cursor support: %s", text)
+ }
+ if !strings.Contains(envelope.NextStep, "sync") {
+ t.Fatalf("empty-store SQL next_step should mention sync: %s", text)
+ }
+}
+
+func TestMCPSQLDomainTableMismatchIsActionable(t *testing.T) {
+ resetMCPPathEnv(t)
+ path, err := mcpDBPath()
+ if err != nil {
+ t.Fatalf("mcpDBPath() error = %v", err)
+ }
+ db, err := store.Open(path)
+ if err != nil {
+ t.Fatalf("creating empty store: %v", err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatalf("closing empty store: %v", err)
+ }
+
+ result, err := handleSQL(context.Background(), mcplib.CallToolRequest{Params: mcplib.CallToolParams{
+ Arguments: map[string]any{"query": "SELECT * FROM widgets"},
+ }})
+ if err != nil {
+ t.Fatalf("handleSQL returned transport error: %v", err)
+ }
+ if result == nil || !result.IsError {
+ t.Fatalf("handleSQL domain-table mismatch IsError = %v, want true", result != nil && result.IsError)
+ }
+ text := mcpTextContent(t, result)
+ for _, want := range []string{"resources(resource_type, id, data)", "resource_type", "json_extract", "widgets"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("domain-table mismatch error %q missing %q", text, want)
+ }
+ }
+}
+
+// TestValidateReadOnlyQuery_AllowsSelectAndWITH pins the contract: the MCP
+// sql tool's allowlist accepts SELECT and WITH-prefix queries, including
+// CTEs, mixed case, leading whitespace, leading SQL comments, and leading
+// statement separators. SELECT-form CTEs ("WITH x AS (SELECT ...) SELECT")
+// must work because novel CLI sql commands in the public library accept
+// them as legitimate read-only queries; the MCP surface keeps parity.
+func TestValidateReadOnlyQuery_AllowsSelectAndWITH(t *testing.T) {
+ allowed := []string{
+ "SELECT 1",
+ "select * from resources",
+ " SELECT 1",
+ "\tSELECT 1",
+ "\nSELECT 1",
+ ";SELECT 1",
+ "-- comment\nSELECT 1",
+ "/* comment */ SELECT 1",
+ "/* comment */SELECT 1",
+ "/**/SELECT 1",
+ "-- one\n-- two\nSELECT 1",
+ "/* a *//* b */ SELECT 1",
+ "WITH r AS (SELECT 1) SELECT * FROM r",
+ "with r as (select 1) select * from r",
+ "SELECT 1;",
+ "SELECT 1; -- trailing comment",
+ "SELECT 1; /* trailing comment */",
+ "SELECT * FROM resources WHERE id = 'a;b'",
+ `SELECT * FROM "semi;colon"`,
+ "SELECT * FROM `semi;colon`",
+ "SELECT * FROM [semi;colon]",
+ }
+ for _, q := range allowed {
+ if err := validateReadOnlyQuery(q); err != nil {
+ t.Errorf("validateReadOnlyQuery(%q) = %v, want nil", q, err)
+ }
+ }
+}
+
+// TestValidateReadOnlyQuery_RejectsBypassVectors covers the comment-prefix
+// bypass class that defeated the earlier prefix-blocklist gate. mode=ro on
+// modernc.org/sqlite does not block VACUUM INTO (writes a fresh file) or
+// ATTACH DATABASE (opens a separate writable handle), so the gate is the
+// only defense against those vectors. A successful bypass at this layer
+// would let an MCP-trusting agent silently exfiltrate the local database.
+func TestValidateReadOnlyQuery_RejectsBypassVectors(t *testing.T) {
+ rejected := []string{
+ "VACUUM INTO '/tmp/x.db'",
+ "ATTACH DATABASE 'file:/tmp/x.db?mode=rwc' AS evil",
+ "INSERT INTO resources VALUES ('x', 'y', '{}')",
+ "UPDATE resources SET resource_type = 'evil'",
+ "DELETE FROM resources",
+ "REPLACE INTO resources VALUES ('seed', 'evil', '{}')",
+ "DROP TABLE resources",
+ "PRAGMA writable_schema = ON",
+ "REINDEX",
+ "DETACH DATABASE x",
+ "/* x */ VACUUM INTO '/tmp/exfil.db'",
+ "/* x */VACUUM INTO '/tmp/exfil.db'",
+ "-- x\nVACUUM INTO '/tmp/exfil.db'",
+ "/**/VACUUM INTO '/tmp/exfil.db'",
+ "/* x */ ATTACH DATABASE 'file:/tmp/x.db?mode=rwc' AS evil",
+ "-- x\nATTACH DATABASE '/tmp/x.db' AS evil",
+ ";VACUUM INTO '/tmp/x.db'",
+ "; ; VACUUM INTO '/tmp/x.db'",
+ "SELECT 1; ATTACH DATABASE 'file:/tmp/x?mode=rwc' AS evil; CREATE TABLE evil.t(x)",
+ "SELECT 1; DROP TABLE resources",
+ "SELECT 1; VACUUM INTO '/tmp/x.db'",
+ "WITH r AS (SELECT 1) SELECT * FROM r; ATTACH DATABASE '/tmp/x.db' AS evil",
+ "SELECT 1 /* c */ ; ATTACH DATABASE '/tmp/x.db' AS evil",
+ "SELECT 'a;b'; VACUUM INTO '/tmp/x.db'",
+ `SELECT * FROM "semi;colon"; ATTACH DATABASE '/tmp/x.db' AS evil`,
+ "/* a */ /* b */ INSERT INTO t VALUES (1)",
+ "/* outer /* not nested */ */ SELECT 1", // SQLite doesn't nest, so trailing "*/" closes; second SELECT remains. Reject — the gate must err on the side of caution when the leading shape is suspicious.
+ "-- only a comment",
+ "/* only a comment */",
+ "",
+ " ",
+ ";",
+ }
+ for _, q := range rejected {
+ if err := validateReadOnlyQuery(q); err == nil {
+ t.Errorf("validateReadOnlyQuery(%q) = nil, want error", q)
+ }
+ }
+}
+
+func TestHasTrailingSQLStatement(t *testing.T) {
+ cases := []struct {
+ name string
+ query string
+ want bool
+ }{
+ {name: "single select", query: "SELECT 1", want: false},
+ {name: "trailing terminator", query: "SELECT 1;", want: false},
+ {name: "trailing terminator and comment", query: "SELECT 1; -- ok", want: false},
+ {name: "two statements", query: "SELECT 1; SELECT 2", want: true},
+ {name: "attach after select", query: "SELECT 1; ATTACH DATABASE '/tmp/x.db' AS evil", want: true},
+ {name: "semicolon in single quote", query: "SELECT 'a;b'", want: false},
+ {name: "escaped single quote", query: "SELECT 'a'';b'", want: false},
+ {name: "semicolon in double quote", query: `SELECT * FROM "a;b"`, want: false},
+ {name: "escaped double quote", query: `SELECT * FROM "a"";b"`, want: false},
+ {name: "semicolon in backtick", query: "SELECT * FROM `a;b`", want: false},
+ {name: "escaped backtick", query: "SELECT * FROM `a``;b`", want: false},
+ {name: "semicolon in bracket", query: "SELECT * FROM [a;b]", want: false},
+ {name: "semicolon in line comment", query: "SELECT 1 -- ;\n", want: false},
+ {name: "statement after line comment", query: "SELECT 1 -- ;\n; SELECT 2", want: true},
+ {name: "semicolon in block comment", query: "SELECT 1 /* ; */", want: false},
+ {name: "statement after block comment", query: "SELECT 1 /* ; */; SELECT 2", want: true},
+ }
+ for _, c := range cases {
+ if got := hasTrailingSQLStatement(c.query); got != c.want {
+ t.Errorf("hasTrailingSQLStatement(%q) [%s] = %v, want %v", c.query, c.name, got, c.want)
+ }
+ }
+}
+
+// TestStripLeadingSQLNoise checks the helper directly so a regression in the
+// stripping logic (off-by-one on /* */ length, missing newline handling on
+// --) surfaces close to the source rather than only via the integration
+// behavior of validateReadOnlyQuery.
+func TestStripLeadingSQLNoise(t *testing.T) {
+ cases := []struct {
+ in, want string
+ }{
+ {"SELECT 1", "SELECT 1"},
+ {" SELECT 1", "SELECT 1"},
+ {"\t\nSELECT 1", "SELECT 1"},
+ {";SELECT 1", "SELECT 1"},
+ {";; ;SELECT 1", "SELECT 1"},
+ {"-- x\nSELECT 1", "SELECT 1"},
+ {"-- x\n-- y\nSELECT 1", "SELECT 1"},
+ {"/* x */SELECT 1", "SELECT 1"},
+ {"/**/SELECT 1", "SELECT 1"},
+ {"/* x */ /* y */ SELECT 1", "SELECT 1"},
+ {"-- only", ""},
+ {"/* only", ""},
+ {"", ""},
+ }
+ for _, c := range cases {
+ got := stripLeadingSQLNoise(c.in)
+ if !strings.EqualFold(got, c.want) {
+ t.Errorf("stripLeadingSQLNoise(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestMCPToolResultTextBoundsListResponses(t *testing.T) {
+ items := make([]string, 0, bound.MaxItems+25)
+ for i := 0; i < bound.MaxItems+25; i++ {
+ items = append(items, strings.Repeat("x", 1600))
+ }
+ data, err := json.Marshal(items)
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := mcpTextContent(t, mcpToolResultText("GET", data))
+ if len(text) > bound.MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(text), bound.MaxBytes)
+ }
+
+ var envelope struct {
+ Count int `json:"count"`
+ Items []json.RawMessage `json:"items"`
+ Truncated bool `json:"truncated"`
+ ReturnedCount int `json:"returned_count"`
+ OriginalBytes int `json:"original_bytes"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded list result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded list result did not mark truncation: %s", text)
+ }
+ if envelope.Count != len(items) {
+ t.Fatalf("count = %d, want %d", envelope.Count, len(items))
+ }
+ if envelope.ReturnedCount != len(envelope.Items) {
+ t.Fatalf("returned_count = %d, want item count %d", envelope.ReturnedCount, len(envelope.Items))
+ }
+ if envelope.OriginalBytes != len(data) {
+ t.Fatalf("original_bytes = %d, want %d", envelope.OriginalBytes, len(data))
+ }
+}
+
+func TestMCPToolResultTextBoundsSingleArrayEnvelope(t *testing.T) {
+ groups := make([]map[string]string, 0, bound.MaxItems+25)
+ for i := 0; i < bound.MaxItems+25; i++ {
+ groups = append(groups, map[string]string{
+ "id": strings.Repeat("g", 8),
+ "name": strings.Repeat("verbose group name ", 90),
+ })
+ }
+ data, err := json.Marshal(map[string]any{
+ "groups": groups,
+ "cursor": "next-page",
+ })
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := mcpTextContent(t, mcpToolResultText("GET", data))
+ if len(text) > bound.MaxBytes {
+ t.Fatalf("bounded result length = %d, want <= %d", len(text), bound.MaxBytes)
+ }
+
+ var envelope struct {
+ Groups []json.RawMessage `json:"groups"`
+ Cursor string `json:"cursor"`
+ Truncated bool `json:"_pp_truncated"`
+ TotalCount int `json:"_pp_total_count"`
+ ReturnedCount int `json:"_pp_returned_count"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("bounded envelope result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("bounded envelope result did not mark truncation: %s", text)
+ }
+ if envelope.Cursor != "next-page" {
+ t.Fatalf("cursor = %q, want preserved metadata", envelope.Cursor)
+ }
+ if envelope.TotalCount != len(groups) {
+ t.Fatalf("total_count = %d, want %d", envelope.TotalCount, len(groups))
+ }
+ if envelope.ReturnedCount != len(envelope.Groups) {
+ t.Fatalf("returned_count = %d, want group count %d", envelope.ReturnedCount, len(envelope.Groups))
+ }
+}
+
+func TestMCPToolResultTextFallsBackToOversizedPreview(t *testing.T) {
+ data, err := json.Marshal(map[string]string{
+ "blob": strings.Repeat("z", bound.MaxBytes+10000),
+ })
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := mcpTextContent(t, mcpToolResultText("GET", data))
+ if len(text) > bound.MaxBytes {
+ t.Fatalf("preview result length = %d, want <= %d", len(text), bound.MaxBytes)
+ }
+
+ var envelope struct {
+ Truncated bool `json:"truncated"`
+ OriginalBytes int `json:"original_bytes"`
+ Preview string `json:"preview"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("preview result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("preview result did not mark truncation: %s", text)
+ }
+ if envelope.OriginalBytes != len(data) {
+ t.Fatalf("original_bytes = %d, want %d", envelope.OriginalBytes, len(data))
+ }
+ if envelope.Preview == "" {
+ t.Fatalf("preview result should include a bounded preview")
+ }
+}
+
+func TestMCPToolResultTextBoundsOversizedNonGETResponses(t *testing.T) {
+ data, err := json.Marshal(map[string]string{
+ "blob": strings.Repeat("z", bound.MaxBytes+10000),
+ })
+ if err != nil {
+ t.Fatalf("marshal fixture: %v", err)
+ }
+
+ text := mcpTextContent(t, mcpToolResultText("POST", data))
+ if len(text) > bound.MaxBytes {
+ t.Fatalf("preview result length = %d, want <= %d", len(text), bound.MaxBytes)
+ }
+
+ var envelope struct {
+ Truncated bool `json:"truncated"`
+ OriginalBytes int `json:"original_bytes"`
+ Preview string `json:"preview"`
+ }
+ if err := json.Unmarshal([]byte(text), &envelope); err != nil {
+ t.Fatalf("preview result must remain valid JSON: %v\n%s", err, text)
+ }
+ if !envelope.Truncated {
+ t.Fatalf("preview result did not mark truncation: %s", text)
+ }
+ if envelope.OriginalBytes != len(data) {
+ t.Fatalf("original_bytes = %d, want %d", envelope.OriginalBytes, len(data))
+ }
+ if envelope.Preview == "" {
+ t.Fatalf("preview result should include a bounded preview")
+ }
+}
+
+func mcpTextContent(t *testing.T, result *mcplib.CallToolResult) string {
+ t.Helper()
+ if result == nil {
+ t.Fatalf("result is nil")
+ }
+ if len(result.Content) != 1 {
+ t.Fatalf("result content length = %d, want 1", len(result.Content))
+ }
+ content, ok := result.Content[0].(mcplib.TextContent)
+ if !ok {
+ t.Fatalf("result content type = %T, want mcp.TextContent", result.Content[0])
+ }
+ return content.Text
+}
diff --git a/library/productivity/human-goat/internal/pricing/allin.go b/library/productivity/human-goat/internal/pricing/allin.go
new file mode 100644
index 0000000000..3793e58fee
--- /dev/null
+++ b/library/productivity/human-goat/internal/pricing/allin.go
@@ -0,0 +1,88 @@
+package pricing
+
+import (
+ "fmt"
+ "math"
+ "strings"
+)
+
+const (
+ // ServiceFeeRate is a calibrated-from-observation default. TaskRabbit's
+ // confirm endpoint is authoritative when available; this estimate is for
+ // browse/ranking surfaces where confirm is not called per Tasker.
+ ServiceFeeRate = 0.15
+
+ // TrustSupportRate is a calibrated-from-observation default subject to metro
+ // variance. It is set to match the observed $33.33 base -> $44.66 all-in
+ // checkout uplift when combined with ServiceFeeRate.
+ TrustSupportRate = 0.19
+)
+
+// Breakdown describes the estimated client-visible all-in hourly price.
+type Breakdown struct {
+ BaseCents int `json:"base_cents"`
+ ServiceFeeCents int `json:"service_fee_cents"`
+ TrustFeeCents int `json:"trust_fee_cents"`
+ AllInCents int `json:"all_in_cents"`
+ State string `json:"state,omitempty"`
+ ServiceFeeOnly bool `json:"service_fee_only"`
+}
+
+// IsServiceFeeOnlyState reports whether TaskRabbit charges only the service
+// fee because of the California/Massachusetts regulatory carve-out.
+func IsServiceFeeOnlyState(state string) bool {
+ switch strings.ToLower(strings.TrimSpace(state)) {
+ case "ca", "california", "ma", "massachusetts":
+ return true
+ default:
+ return false
+ }
+}
+
+// AllIn estimates TaskRabbit's all-in hourly client price from the browse
+// poster_hourly_rate_cents base rate. The confirm endpoint returns the real
+// all-in total and is authoritative; this estimate is intended only for
+// ranking/browse flows where confirm is not called per Tasker.
+func AllIn(baseCents int, state string) Breakdown {
+ if baseCents < 0 {
+ baseCents = 0
+ }
+
+ cleanState := strings.TrimSpace(state)
+ serviceFee := roundCents(float64(baseCents) * ServiceFeeRate)
+ serviceFeeOnly := IsServiceFeeOnlyState(cleanState)
+
+ trustFee := 0
+ if !serviceFeeOnly {
+ trustFee = roundCents(float64(baseCents) * TrustSupportRate)
+ }
+
+ return Breakdown{
+ BaseCents: baseCents,
+ ServiceFeeCents: serviceFee,
+ TrustFeeCents: trustFee,
+ AllInCents: baseCents + serviceFee + trustFee,
+ State: cleanState,
+ ServiceFeeOnly: serviceFeeOnly,
+ }
+}
+
+// AllInDollars returns the estimated all-in hourly client price as dollars.
+func AllInDollars(baseCents int, state string) float64 {
+ return float64(AllIn(baseCents, state).AllInCents) / 100.0
+}
+
+// FormatCents formats a cent amount as US dollars.
+func FormatCents(cents int) string {
+ sign := ""
+ if cents < 0 {
+ sign = "-"
+ cents = -cents
+ }
+
+ return fmt.Sprintf("%s$%d.%02d", sign, cents/100, cents%100)
+}
+
+func roundCents(value float64) int {
+ return int(math.Round(value))
+}
diff --git a/library/productivity/human-goat/internal/pricing/allin_test.go b/library/productivity/human-goat/internal/pricing/allin_test.go
new file mode 100644
index 0000000000..147f9c7527
--- /dev/null
+++ b/library/productivity/human-goat/internal/pricing/allin_test.go
@@ -0,0 +1,92 @@
+package pricing
+
+import "testing"
+
+func TestAllIn_StandardMetro(t *testing.T) {
+ got := AllIn(3333, "")
+
+ if diff := abs(got.AllInCents - 4466); diff > 2 {
+ t.Fatalf("AllInCents = %d, want within +/-2 cents of 4466", got.AllInCents)
+ }
+ if got.ServiceFeeCents <= 0 {
+ t.Fatalf("ServiceFeeCents = %d, want > 0", got.ServiceFeeCents)
+ }
+ if got.TrustFeeCents <= 0 {
+ t.Fatalf("TrustFeeCents = %d, want > 0", got.TrustFeeCents)
+ }
+ if got.AllInCents != got.BaseCents+got.ServiceFeeCents+got.TrustFeeCents {
+ t.Fatalf("AllInCents = %d, want base + fees = %d", got.AllInCents, got.BaseCents+got.ServiceFeeCents+got.TrustFeeCents)
+ }
+}
+
+func TestAllIn_CaliforniaServiceFeeOnly(t *testing.T) {
+ got := AllIn(3333, "CA")
+
+ if got.TrustFeeCents != 0 {
+ t.Fatalf("TrustFeeCents = %d, want 0", got.TrustFeeCents)
+ }
+ if !got.ServiceFeeOnly {
+ t.Fatal("ServiceFeeOnly = false, want true")
+ }
+ if got.AllInCents != 3333+got.ServiceFeeCents {
+ t.Fatalf("AllInCents = %d, want base + service fee = %d", got.AllInCents, 3333+got.ServiceFeeCents)
+ }
+}
+
+func TestAllIn_Massachusetts(t *testing.T) {
+ got := AllIn(3333, "Massachusetts")
+
+ if !got.ServiceFeeOnly {
+ t.Fatal("ServiceFeeOnly = false, want true")
+ }
+}
+
+func TestIsServiceFeeOnlyState(t *testing.T) {
+ tests := map[string]bool{
+ "CA": true,
+ "MA": true,
+ "California": true,
+ "Massachusetts": true,
+ "ca": true,
+ "massachusetts": true,
+ "WA": false,
+ "NY": false,
+ "": false,
+ }
+
+ for state, want := range tests {
+ if got := IsServiceFeeOnlyState(state); got != want {
+ t.Fatalf("IsServiceFeeOnlyState(%q) = %v, want %v", state, got, want)
+ }
+ }
+}
+
+func TestFormatCents(t *testing.T) {
+ tests := map[int]string{
+ 4466: "$44.66",
+ 3300: "$33.00",
+ 5: "$0.05",
+ }
+
+ for cents, want := range tests {
+ if got := FormatCents(cents); got != want {
+ t.Fatalf("FormatCents(%d) = %q, want %q", cents, got, want)
+ }
+ }
+}
+
+func TestAllIn_NegativeClamped(t *testing.T) {
+ got := AllIn(-100, "")
+
+ if got.AllInCents != 0 {
+ t.Fatalf("AllInCents = %d, want 0", got.AllInCents)
+ }
+}
+
+func abs(n int) int {
+ if n < 0 {
+ return -n
+ }
+
+ return n
+}
diff --git a/library/productivity/human-goat/internal/source/magic/magic.go b/library/productivity/human-goat/internal/source/magic/magic.go
new file mode 100644
index 0000000000..6d539ab69c
--- /dev/null
+++ b/library/productivity/human-goat/internal/source/magic/magic.go
@@ -0,0 +1,420 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+
+package magic
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/cliutil"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+ "unicode"
+)
+
+const (
+ defaultBaseURL = "https://console.api.getmagic.com/api/v1"
+ defaultRateLimit = 2.0
+ defaultTimeout = 30 * time.Second
+ maxRateLimitRetries = 3
+ maxErrorSnippetBytes = 500
+)
+
+// Request is a Magic remote-errand request.
+type Request struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Title string `json:"title"`
+ CompletedAt string `json:"completed_at"`
+ Result string `json:"result"`
+ Conversation []ConversationMessage `json:"conversation"`
+}
+
+// MarshalJSON keeps empty conversation collections encoded as [] instead of null.
+func (r Request) MarshalJSON() ([]byte, error) {
+ type requestAlias Request
+ out := requestAlias(r)
+ if out.Conversation == nil {
+ out.Conversation = make([]ConversationMessage, 0)
+ }
+ return json.Marshal(out)
+}
+
+// UnmarshalJSON keeps absent conversation collections as empty slices.
+func (r *Request) UnmarshalJSON(data []byte) error {
+ type requestAlias Request
+ var out requestAlias
+ if err := json.Unmarshal(data, &out); err != nil {
+ return err
+ }
+ *r = Request(out)
+ normalizeRequest(r)
+ return nil
+}
+
+// ConversationMessage is a single Magic conversation message.
+type ConversationMessage struct {
+ Content string `json:"content"`
+}
+
+// SendParams describes a Magic request to create.
+type SendParams struct {
+ Title string
+ Instructions string
+ Objective string
+ MaxMinutes *int
+ Relaxed bool
+ Tag string
+}
+
+// Client is a small Magic API client.
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+ limiter *cliutil.AdaptiveLimiter
+ keyProvider func() (string, error)
+}
+
+// NewClient returns a Magic client configured from environment variables.
+func NewClient() (*Client, error) {
+ baseURL := strings.TrimSpace(os.Getenv("MAGIC_BASE"))
+ if baseURL == "" {
+ baseURL = defaultBaseURL
+ }
+ baseURL = strings.TrimRight(baseURL, "/")
+ if err := validateBaseURL(baseURL); err != nil {
+ return nil, err
+ }
+ return &Client{
+ baseURL: baseURL,
+ httpClient: &http.Client{Timeout: defaultTimeout},
+ limiter: cliutil.NewAdaptiveLimiter(defaultRateLimit),
+ keyProvider: ResolveKey,
+ }, nil
+}
+
+// ResolveKey resolves the Magic API key from MAGIC_API_KEY, then api_key in
+// MAGIC_CONFIG_DIR or $HOME/.magic.
+func ResolveKey() (string, error) {
+ if key := strings.TrimSpace(os.Getenv("MAGIC_API_KEY")); key != "" {
+ return key, nil
+ }
+
+ keyPath, err := magicAPIKeyPath()
+ if err != nil {
+ return "", err
+ }
+ // #nosec G304 -- keyPath derives only from MAGIC_CONFIG_DIR or $HOME/.magic, a trusted config location, never user-supplied request input.
+ keyBytes, err := os.ReadFile(keyPath)
+ if err == nil {
+ if key := strings.TrimRightFunc(string(keyBytes), unicode.IsSpace); key != "" {
+ return key, nil
+ }
+ return "", fmt.Errorf("magic: API key not found; set MAGIC_API_KEY or write a non-empty key to MAGIC_CONFIG_DIR/api_key (%s)", keyPath)
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ return "", fmt.Errorf("magic: API key not found; set MAGIC_API_KEY or write key to MAGIC_CONFIG_DIR/api_key (%s)", keyPath)
+ }
+ return "", fmt.Errorf("magic: API key not found in MAGIC_API_KEY and could not read MAGIC_CONFIG_DIR/api_key (%s): %w", keyPath, err)
+}
+
+// Send creates a Magic request.
+func (c *Client) Send(ctx context.Context, params SendParams) (*Request, error) {
+ if strings.TrimSpace(params.Title) == "" {
+ return nil, errors.New("magic: title is required")
+ }
+ if strings.TrimSpace(params.Instructions) == "" {
+ return nil, errors.New("magic: instructions are required")
+ }
+ if strings.TrimSpace(params.Objective) == "" {
+ return nil, errors.New("magic: objective is required")
+ }
+
+ payload := sendPayload{
+ Title: params.Title,
+ Instructions: params.Instructions,
+ Objective: params.Objective,
+ MaxMinutes: params.MaxMinutes,
+ Relaxed: params.Relaxed,
+ }
+ if strings.TrimSpace(params.Tag) != "" {
+ payload.CallbackMeta = &callbackMeta{Tag: params.Tag}
+ }
+
+ body, err := c.do(ctx, http.MethodPost, "/request", payload)
+ if err != nil {
+ return nil, err
+ }
+ var request Request
+ if err := json.Unmarshal(body, &request); err != nil {
+ return nil, fmt.Errorf("magic: decode request: %w", err)
+ }
+ normalizeRequest(&request)
+ return &request, nil
+}
+
+// Call creates a Magic request to make a phone call and report the answer.
+func (c *Client) Call(ctx context.Context, number, ask string) (*Request, error) {
+ if strings.TrimSpace(number) == "" {
+ return nil, errors.New("magic: number is required")
+ }
+ if strings.TrimSpace(ask) == "" {
+ return nil, errors.New("magic: ask is required")
+ }
+ return c.Send(ctx, SendParams{
+ Title: "Phone call: " + number,
+ Instructions: "Call " + number + ". Ask: " + ask + ". Report the answer verbatim. If there is no answer, the line is busy, or the business is closed, say so and include any hours or voicemail message you hear.",
+ Objective: "Get the answer to: " + ask,
+ Tag: number,
+ })
+}
+
+// GetRequest fetches a Magic request by ID.
+func (c *Client) GetRequest(ctx context.Context, id string) (*Request, error) {
+ if strings.TrimSpace(id) == "" {
+ return nil, errors.New("magic: request id is required")
+ }
+ body, err := c.do(ctx, http.MethodGet, "/request/"+url.PathEscape(id), nil)
+ if err != nil {
+ return nil, err
+ }
+ var request Request
+ if err := json.Unmarshal(body, &request); err != nil {
+ return nil, fmt.Errorf("magic: decode request: %w", err)
+ }
+ normalizeRequest(&request)
+ return &request, nil
+}
+
+// Reply posts a conversation reply for a Magic request.
+func (c *Client) Reply(ctx context.Context, requestID, content string) (map[string]any, error) {
+ if strings.TrimSpace(requestID) == "" {
+ return nil, errors.New("magic: request id is required")
+ }
+ if strings.TrimSpace(content) == "" {
+ return nil, errors.New("magic: content is required")
+ }
+ body, err := c.do(ctx, http.MethodPost, "/conversation", map[string]string{
+ "content": content,
+ "request_id": requestID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := make(map[string]any)
+ if len(bytes.TrimSpace(body)) == 0 {
+ return out, nil
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("magic: decode conversation response: %w", err)
+ }
+ if out == nil {
+ out = make(map[string]any)
+ }
+ return out, nil
+}
+
+// IsInProgress reports whether a Magic status should be treated as non-terminal.
+func IsInProgress(status string) bool {
+ switch strings.ToUpper(strings.TrimSpace(status)) {
+ case "":
+ return false
+ case "COMPLETED", "DONE", "CANCELLED", "CANCELED", "FAILED", "CLOSED":
+ return false
+ case "PENDING", "ONGOING", "IN_PROGRESS", "ASSIGNED", "ACTIVE", "CREATED", "QUEUED":
+ return true
+ default:
+ return true
+ }
+}
+
+// Answer returns the best available human answer text for a Magic request.
+func (r *Request) Answer() string {
+ if r == nil {
+ return ""
+ }
+ for i := len(r.Conversation) - 1; i >= 0; i-- {
+ if content := strings.TrimSpace(r.Conversation[i].Content); content != "" {
+ return r.Conversation[i].Content
+ }
+ }
+ return r.Result
+}
+
+func (c *Client) do(ctx context.Context, method, path string, body any) ([]byte, error) {
+ if c == nil {
+ return nil, errors.New("magic: nil client")
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ keyProvider := c.keyProvider
+ if keyProvider == nil {
+ keyProvider = ResolveKey
+ }
+ key, err := keyProvider()
+ if err != nil {
+ return nil, fmt.Errorf("magic: resolving API key: %w", err)
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return nil, errors.New("magic: resolved API key is empty")
+ }
+
+ var encoded []byte
+ if body != nil {
+ encoded, err = json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("magic: encode request body: %w", err)
+ }
+ }
+
+ targetURL, err := c.urlFor(path)
+ if err != nil {
+ return nil, err
+ }
+ httpClient := c.httpClient
+ if httpClient == nil {
+ httpClient = http.DefaultClient
+ }
+
+ for attempt := 0; ; attempt++ {
+ c.limiter.Wait()
+ var reqBody io.Reader
+ if encoded != nil {
+ reqBody = bytes.NewReader(encoded)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, targetURL, reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("magic: build request %s %s: %w", method, path, err)
+ }
+ req.Header.Set("x-api-key", key)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return nil, ctxErr
+ }
+ return nil, fmt.Errorf("magic: %s %s: %w", method, path, err)
+ }
+
+ respBody, readErr := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if readErr != nil {
+ return nil, fmt.Errorf("magic: read response %s %s: %w", method, path, readErr)
+ }
+
+ if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
+ c.limiter.OnSuccess()
+ return respBody, nil
+ }
+
+ if resp.StatusCode == http.StatusTooManyRequests {
+ c.limiter.OnRateLimit()
+ wait := cliutil.RetryAfter(resp)
+ if attempt >= maxRateLimitRetries {
+ return nil, &cliutil.RateLimitError{
+ URL: targetURL,
+ RetryAfter: wait,
+ Body: errorSnippet(respBody),
+ }
+ }
+ if err := sleepContext(ctx, wait); err != nil {
+ return nil, err
+ }
+ continue
+ }
+
+ return nil, fmt.Errorf("magic: HTTP %d %s %s: %s", resp.StatusCode, method, path, errorSnippet(respBody))
+ }
+}
+
+type sendPayload struct {
+ Title string `json:"title"`
+ Instructions string `json:"instructions"`
+ Objective string `json:"objective"`
+ MaxMinutes *int `json:"max_minutes,omitempty"`
+ Relaxed bool `json:"relaxed,omitempty"`
+ CallbackMeta *callbackMeta `json:"callback_meta,omitempty"`
+}
+
+type callbackMeta struct {
+ Tag string `json:"tag"`
+}
+
+func validateBaseURL(baseURL string) error {
+ parsed, err := url.Parse(baseURL)
+ if err != nil {
+ return fmt.Errorf("magic: invalid MAGIC_BASE %q: %w", baseURL, err)
+ }
+ if parsed.Scheme == "" || parsed.Host == "" {
+ return fmt.Errorf("magic: invalid MAGIC_BASE %q: must include scheme and host", baseURL)
+ }
+ return nil
+}
+
+func (c *Client) urlFor(path string) (string, error) {
+ baseURL := strings.TrimRight(c.baseURL, "/")
+ if baseURL == "" {
+ baseURL = defaultBaseURL
+ }
+ if err := validateBaseURL(baseURL); err != nil {
+ return "", err
+ }
+ if path == "" {
+ return baseURL, nil
+ }
+ if !strings.HasPrefix(path, "/") {
+ path = "/" + path
+ }
+ return baseURL + path, nil
+}
+
+func magicAPIKeyPath() (string, error) {
+ configDir := strings.TrimSpace(os.Getenv("MAGIC_CONFIG_DIR"))
+ if configDir == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("magic: API key not found; set MAGIC_API_KEY or MAGIC_CONFIG_DIR/api_key (default $HOME/.magic/api_key): %w", err)
+ }
+ configDir = filepath.Join(home, ".magic")
+ }
+ return filepath.Join(configDir, "api_key"), nil
+}
+
+func normalizeRequest(request *Request) {
+ if request != nil && request.Conversation == nil {
+ request.Conversation = make([]ConversationMessage, 0)
+ }
+}
+
+func errorSnippet(body []byte) string {
+ snippet := strings.TrimSpace(string(body))
+ if len(snippet) <= maxErrorSnippetBytes {
+ return snippet
+ }
+ return snippet[:maxErrorSnippetBytes] + "..."
+}
+
+func sleepContext(ctx context.Context, wait time.Duration) error {
+ if wait <= 0 {
+ return nil
+ }
+ timer := time.NewTimer(wait)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
diff --git a/library/productivity/human-goat/internal/source/magic/magic_test.go b/library/productivity/human-goat/internal/source/magic/magic_test.go
new file mode 100644
index 0000000000..8fefaaf618
--- /dev/null
+++ b/library/productivity/human-goat/internal/source/magic/magic_test.go
@@ -0,0 +1,59 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+
+package magic
+
+import "testing"
+
+func TestIsInProgress(t *testing.T) {
+ tests := []struct {
+ name string
+ status string
+ want bool
+ }{
+ {name: "pending", status: "PENDING", want: true},
+ {name: "ongoing", status: "ONGOING", want: true},
+ {name: "completed", status: "COMPLETED", want: false},
+ {name: "cancelled", status: "CANCELLED", want: false},
+ {name: "failed", status: "FAILED", want: false},
+ {name: "unknown non-empty", status: "WEIRD", want: true},
+ {name: "empty", status: "", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsInProgress(tt.status); got != tt.want {
+ t.Fatalf("IsInProgress(%q) = %v, want %v", tt.status, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestAnswer(t *testing.T) {
+ t.Run("last non-empty conversation content", func(t *testing.T) {
+ request := Request{
+ Result: "fallback result",
+ Conversation: []ConversationMessage{
+ {Content: "first"},
+ {Content: " "},
+ {Content: "last"},
+ },
+ }
+ if got := request.Answer(); got != "last" {
+ t.Fatalf("Answer() = %q, want %q", got, "last")
+ }
+ })
+
+ t.Run("result fallback", func(t *testing.T) {
+ request := Request{Result: "from result", Conversation: []ConversationMessage{}}
+ if got := request.Answer(); got != "from result" {
+ t.Fatalf("Answer() = %q, want %q", got, "from result")
+ }
+ })
+
+ t.Run("empty", func(t *testing.T) {
+ request := Request{Conversation: []ConversationMessage{}}
+ if got := request.Answer(); got != "" {
+ t.Fatalf("Answer() = %q, want empty", got)
+ }
+ })
+}
diff --git a/library/productivity/human-goat/internal/source/taskrabbit/trpc.go b/library/productivity/human-goat/internal/source/taskrabbit/trpc.go
new file mode 100644
index 0000000000..d87d3ba5f7
--- /dev/null
+++ b/library/productivity/human-goat/internal/source/taskrabbit/trpc.go
@@ -0,0 +1,741 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+
+package taskrabbit
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/client"
+)
+
+const maxTRPCErrorMessageRunes = 300
+
+// The checkout commit uses the captured REST endpoint behind the funnel
+// "Confirm and chat" button.
+
+// Client wraps the generated TaskRabbit client with tRPC/BFF helpers.
+type Client struct {
+ c *client.Client
+}
+
+// New returns a TaskRabbit tRPC/BFF source adapter using the generated client.
+func New(c *client.Client) *Client {
+ return &Client{c: c}
+}
+
+// TRPCError is a typed tRPC envelope error.
+type TRPCError struct {
+ Proc string
+ Code string
+ Message string
+}
+
+func (e *TRPCError) Error() string {
+ if e == nil {
+ return "taskrabbit: "
+ }
+ return fmt.Sprintf("taskrabbit %s: %s: %s", e.Proc, e.Code, truncateRunes(e.Message, maxTRPCErrorMessageRunes))
+}
+
+// Booking is a permissive TaskRabbit task-list item.
+type Booking struct {
+ ID string `json:"id"`
+ JobID int `json:"job_id"` // details.id — the cancelTask jobId
+ RabbitID int `json:"rabbit_id"` // taskers[0].id — the cancelTask rabbitId
+ Appointment string `json:"appointment,omitempty"`
+ TaskerName string `json:"tasker_name,omitempty"`
+ Status string `json:"status"`
+ Taskers json.RawMessage `json:"taskers"`
+ FutureAppointments json.RawMessage `json:"future_appointments"`
+ Raw json.RawMessage `json:"-"`
+}
+
+// RecommendationsInput is the page.book.recommendations BFF input.
+// RecommendationsInput is the full input page.book.recommendations requires.
+// Verified live 2026-07-03: the endpoint is stateful but does NOT need a real
+// job-draft — it needs bootstrap+isRecosPage+funnelId+categoryId+top-level
+// lat/lng + a location object + a schedule. The funnelId is NOT validated
+// server-side (a fabricated "_" returns full results), so the CLI
+// synthesizes one instead of driving the funnel UI.
+type RecommendationsInput struct {
+ Bootstrap bool `json:"bootstrap"`
+ CategoryID int `json:"categoryId"`
+ FunnelID string `json:"funnelId"`
+ IsRecosPage bool `json:"isRecosPage"`
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+ Locale string `json:"locale"`
+ Location map[string]any `json:"location"`
+ Schedule ScheduleInput `json:"schedule"`
+ TaskTemplateID any `json:"taskTemplateId,omitempty"`
+}
+
+// BuildRecommendationsInput assembles the full recommendations input from the
+// essentials a CLI command has: category, template, coordinates, and the dates
+// to search. It fabricates a funnelId and fills the required funnel flags.
+func BuildRecommendationsInput(categoryID, templateID int, lat, lng float64, dates []string) RecommendationsInput {
+ specs := make([]DateSpec, 0, len(dates))
+ for _, d := range dates {
+ specs = append(specs, NewDateSpec(d))
+ }
+ return RecommendationsInput{
+ Bootstrap: true,
+ CategoryID: categoryID,
+ FunnelID: synthFunnelID(),
+ IsRecosPage: true,
+ Lat: lat,
+ Lng: lng,
+ Locale: "en-US",
+ Location: map[string]any{"lat": lat, "lng": lng},
+ Schedule: ScheduleInput{Dates: specs, DayTimeRanges: []any{}},
+ TaskTemplateID: templateID,
+ }
+}
+
+// SynthFunnelID returns a shape-valid funnel id for TaskRabbit book flows.
+func SynthFunnelID() string {
+ return synthFunnelID()
+}
+
+// synthFunnelID returns a "_" funnel id. The server does not
+// validate it; it only needs the shape.
+func synthFunnelID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ // deterministic fallback; still shape-valid
+ copy(b, []byte("humangoatfunnel!"))
+ }
+ uuid := fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
+ return fmt.Sprintf("%s_%d", uuid, time.Now().UnixMilli())
+}
+
+// SynthSessionID returns a shape-valid session id for TaskRabbit checkout.
+func SynthSessionID() string {
+ return synthSessionID()
+}
+
+// synthSessionID returns a 52-character lower-case hex-ish session token.
+func synthSessionID() string {
+ b := make([]byte, 26)
+ if _, err := rand.Read(b); err != nil {
+ copy(b, []byte("humangoattaskrabbitcheckout"))
+ }
+ return hex.EncodeToString(b)
+}
+
+// ScheduleInput is the availability window sent to recommendations.
+type ScheduleInput struct {
+ Dates []DateSpec `json:"dates"`
+ DayTimeRanges []any `json:"dayTimeRanges"`
+}
+
+// DateSpec is one element of schedule.dates. Verified live 2026-07-03: the
+// recommendations endpoint requires date + duration_seconds + offset_seconds
+// (all three), not a bare date string.
+type DateSpec struct {
+ Date string `json:"date"` // YYYY-MM-DD
+ DurationSeconds int `json:"duration_seconds"` // job length estimate, e.g. 3600 (1h)
+ OffsetSeconds int `json:"offset_seconds"` // start offset within the day; 0 = any time
+}
+
+// NewDateSpec builds a DateSpec with sensible defaults (1-hour job, any start time).
+func NewDateSpec(date string) DateSpec {
+ return DateSpec{Date: date, DurationSeconds: 86400, OffsetSeconds: 0}
+}
+
+type Address struct {
+ Address1 string `json:"address1"`
+ Address2 string `json:"address2"`
+ Country string `json:"country"`
+ FormattedAddress string `json:"formatted_address"`
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+ Locality string `json:"locality"`
+ MetroID int `json:"metro_id"`
+ MetroName string `json:"metro_name"`
+ PostalCode string `json:"postal_code"`
+ Region string `json:"region"`
+}
+
+type HireInput struct {
+ Source string `json:"source"`
+ JobType string `json:"job_type"`
+ FixedRate bool `json:"fixed_rate"`
+ SecondsBetween string `json:"seconds_between"`
+ ShownCancellationPolicy bool `json:"shown_cancellation_policy"`
+ TaskTemplateID int `json:"task_template_id"`
+ CategoryID int `json:"category_id"`
+ CategoryName string `json:"category_name"`
+ Title string `json:"title"`
+ MarketingGroupID int `json:"marketing_group_id"`
+ FunnelID string `json:"funnel_id"`
+ SessionID string `json:"session_id"`
+ RecommendationID string `json:"recommendation_id"`
+ InviteeID int `json:"invitee_id"`
+ RabbitID int `json:"rabbit_id"`
+ PosterHourlyRateCents int `json:"poster_hourly_rate_cents"`
+ JobDraftGUID string `json:"job_draft_guid"`
+ FormReferrer string `json:"form_referrer"`
+ JobSize string `json:"job_size"`
+ Description string `json:"description"`
+ Schedule DateSpec `json:"schedule"`
+ Address Address `json:"address"`
+ SecondaryLocation *Address `json:"secondary_location,omitempty"`
+}
+
+// Tasker is a permissive recommendation item.
+type Tasker struct {
+ ID string `json:"id"`
+ UserID any `json:"user_id"`
+ UserIDInt int `json:"user_id_int"`
+ FirstName string `json:"first_name"`
+ DisplayName string `json:"display_name"`
+ PosterHourlyRateCents int `json:"poster_hourly_rate_cents"`
+ PosterRateCurrency string `json:"poster_hourly_rate_currency"`
+ RabbitRating float64 `json:"rabbit_rating"`
+ RabbitReviews int `json:"rabbit_number_of_reviews"`
+ CategoryInvoicesCount int `json:"category_invoices_count"`
+ HoursWorked float64 `json:"hours_worked"`
+ Elite bool `json:"elite"`
+ ReliabilityRate any `json:"reliability_rate"`
+ NextAvailableAt string `json:"next_available_at"`
+ IsFavorite bool `json:"is_favorite"`
+ PastTasker bool `json:"past_tasker"`
+ TwoHourMinimum any `json:"two_hour_minimum_required_display"`
+ Raw json.RawMessage `json:"-"`
+}
+
+// flexFloat/flexInt/flexBool tolerate TaskRabbit encoding numerics and booleans
+// as JSON strings (e.g. "5.0", "932", "true") instead of native types.
+type flexFloat float64
+type flexInt int
+type flexBool bool
+
+func (f *flexFloat) UnmarshalJSON(b []byte) error {
+ s := strings.Trim(strings.TrimSpace(string(b)), `"`)
+ if s == "" || s == "null" {
+ *f = 0
+ return nil
+ }
+ v, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ *f = 0
+ return nil
+ }
+ *f = flexFloat(v)
+ return nil
+}
+
+func (i *flexInt) UnmarshalJSON(b []byte) error {
+ s := strings.Trim(strings.TrimSpace(string(b)), `"`)
+ if s == "" || s == "null" {
+ *i = 0
+ return nil
+ }
+ if v, err := strconv.ParseInt(s, 10, 64); err == nil {
+ *i = flexInt(v)
+ return nil
+ }
+ // tolerate "3300.0"
+ if v, err := strconv.ParseFloat(s, 64); err == nil {
+ *i = flexInt(int64(v))
+ }
+ return nil
+}
+
+func (bl *flexBool) UnmarshalJSON(b []byte) error {
+ s := strings.Trim(strings.TrimSpace(string(b)), `"`)
+ *bl = flexBool(s == "true" || s == "1")
+ return nil
+}
+
+// UnmarshalJSON tolerates string-encoded numerics/booleans in the Tasker payload.
+func (t *Tasker) UnmarshalJSON(b []byte) error {
+ type shadow struct {
+ ID string `json:"id"`
+ UserID json.RawMessage `json:"user_id"`
+ FirstName string `json:"first_name"`
+ DisplayName string `json:"display_name"`
+ PosterHourlyRateCents flexInt `json:"poster_hourly_rate_cents"`
+ PosterRateCurrency string `json:"poster_hourly_rate_currency"`
+ // The 0-5 star rating the app shows is category_family_average_star_rating;
+ // rabbit_rating is a "100%" positive-review string, not a star rating.
+ RabbitRating flexFloat `json:"category_family_average_star_rating"`
+ RabbitReviews flexInt `json:"category_family_review_count"`
+ CategoryInvoicesCount flexInt `json:"category_invoices_count"`
+ HoursWorked flexFloat `json:"hours_worked"`
+ Elite flexBool `json:"elite"`
+ ReliabilityRate any `json:"reliability_rate"`
+ NextAvailableAt string `json:"next_available_at"`
+ IsFavorite flexBool `json:"is_favorite"`
+ PastTasker flexBool `json:"past_tasker"`
+ TwoHourMinimum any `json:"two_hour_minimum_required_display"`
+ }
+ var s shadow
+ if err := json.Unmarshal(b, &s); err != nil {
+ return err
+ }
+ var userID any
+ if len(s.UserID) > 0 {
+ _ = json.Unmarshal(s.UserID, &userID)
+ }
+ t.ID = s.ID
+ t.UserID = userID
+ t.UserIDInt = intFromRaw(s.UserID)
+ t.FirstName = s.FirstName
+ t.DisplayName = s.DisplayName
+ t.PosterHourlyRateCents = int(s.PosterHourlyRateCents)
+ t.PosterRateCurrency = s.PosterRateCurrency
+ t.RabbitRating = float64(s.RabbitRating)
+ t.RabbitReviews = int(s.RabbitReviews)
+ t.CategoryInvoicesCount = int(s.CategoryInvoicesCount)
+ t.HoursWorked = float64(s.HoursWorked)
+ t.Elite = bool(s.Elite)
+ t.ReliabilityRate = s.ReliabilityRate
+ t.NextAvailableAt = s.NextAvailableAt
+ t.IsFavorite = bool(s.IsFavorite)
+ t.PastTasker = bool(s.PastTasker)
+ t.TwoHourMinimum = s.TwoHourMinimum
+ t.Raw = append(json.RawMessage(nil), b...)
+ return nil
+}
+
+// Histogram describes recommendation price distribution.
+type Histogram struct {
+ MinimumPriceCents int `json:"minimum_price_cents"`
+ MedianPriceCents int `json:"median_price_cents"`
+ MaximumPriceCents int `json:"maximum_price_cents"`
+ CurrencyCode string `json:"currency_code"`
+}
+
+// AvailableDate is one day of TaskRabbit schedule availability.
+type AvailableDate struct {
+ Date string `json:"date"`
+ Sameday bool `json:"sameday"`
+ Slots []Slot `json:"slots"`
+}
+
+// Slot is one available appointment slot.
+type Slot struct {
+ DurationSeconds int `json:"durationSeconds"`
+ OffsetSeconds int `json:"offsetSeconds"`
+ SelectLabel string `json:"selectLabel"`
+}
+
+// Query calls a TaskRabbit tRPC query procedure and returns result.data.json.
+func (t *Client) Query(ctx context.Context, proc string, input any) (json.RawMessage, error) {
+ encoded, err := json.Marshal(trpcInputEnvelope(input))
+ if err != nil {
+ return nil, fmt.Errorf("taskrabbit %s: encode tRPC input: %w", proc, err)
+ }
+ body, err := t.c.Get(ctx, "/next-api/trpc/"+proc, map[string]string{
+ "batch": "1",
+ "input": string(encoded),
+ })
+ if err != nil {
+ return nil, err
+ }
+ return unwrapTRPC(proc, body)
+}
+
+// Mutation calls a TaskRabbit tRPC mutation procedure and returns result.data.json.
+func (t *Client) Mutation(ctx context.Context, proc string, input any, csrfToken string) (json.RawMessage, error) {
+ body, _, err := t.c.PostWithHeaders(ctx, "/next-api/trpc/"+proc+"?batch=1", trpcInputEnvelope(input), map[string]string{
+ "X-CSRF-Token": csrfToken,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return unwrapTRPC(proc, body)
+}
+
+// Hire posts the captured REST checkout commit. The caller supplies the
+// metro_id-bearing address and accepts that SynthFunnelID/SynthSessionID match
+// the captured shapes but are not re-verified against a fresh booking here.
+func (t *Client) Hire(ctx context.Context, in HireInput, csrfToken string) (json.RawMessage, error) {
+ body, status, err := t.c.PostWithHeaders(ctx, "/api/v3/jobs/post/hire.json", in, map[string]string{
+ "X-CSRF-Token": csrfToken,
+ })
+ if err != nil {
+ var apiErr *client.APIError
+ if errors.As(err, &apiErr) {
+ return nil, fmt.Errorf("taskrabbit hire: HTTP %d: %s", apiErr.StatusCode, truncateRunes(apiErr.Body, maxTRPCErrorMessageRunes))
+ }
+ if status >= 300 {
+ return nil, fmt.Errorf("taskrabbit hire: HTTP %d: %s", status, truncateRunes(err.Error(), maxTRPCErrorMessageRunes))
+ }
+ return nil, err
+ }
+ if status < 200 || status >= 300 {
+ return nil, fmt.Errorf("taskrabbit hire: HTTP %d: %s", status, truncateRunes(string(body), maxTRPCErrorMessageRunes))
+ }
+ return body, nil
+}
+
+// ListTasks reads the TaskRabbit task-list BFF.
+func (t *Client) ListTasks(ctx context.Context, page, perPage int, filters map[string]any, locale string) ([]Booking, error) {
+ if filters == nil {
+ filters = make(map[string]any)
+ }
+ // page.tasks.list requires filters.status; it is a Zod enum ("active" | "completed").
+ // Default to "active" so a bare `tasks list` returns current bookings instead of 400.
+ if _, ok := filters["status"]; !ok {
+ filters["status"] = "active"
+ }
+ payload, err := t.Query(ctx, "page.tasks.list", struct {
+ Page int `json:"page"`
+ PerPage int `json:"perPage"`
+ Filters map[string]any `json:"filters"`
+ Locale string `json:"locale"`
+ }{
+ Page: page,
+ PerPage: perPage,
+ Filters: filters,
+ Locale: locale,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return parseListTasksPayload(payload)
+}
+
+// Recommendations reads recommended TaskRabbit taskers.
+func (t *Client) Recommendations(ctx context.Context, input RecommendationsInput) ([]Tasker, Histogram, string, error) {
+ payload, err := t.Query(ctx, "page.book.recommendations", input)
+ if err != nil {
+ return nil, Histogram{}, "", err
+ }
+ // The endpoint returns HTTP 200 with a domain-level {"error":{...}} body when
+ // called without a valid funnel job-draft (recommendations is stateful: the
+ // funnel `details` step must create a job draft first). Surface it loudly
+ // instead of returning an empty list that looks like "no Taskers available".
+ var envelope struct {
+ Error *struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if json.Unmarshal(payload, &envelope) == nil && envelope.Error != nil {
+ return nil, Histogram{}, "", fmt.Errorf("taskrabbit recommendations: %s: %s (recommendations requires a funnel job-draft; the details step must run first)", envelope.Error.Code, envelope.Error.Message)
+ }
+ return parseRecommendationsPayloadWithID(payload)
+}
+
+// Schedule reads TaskRabbit availability for a tasker/category.
+func (t *Client) Schedule(ctx context.Context, categoryID, inviteeID int, locale string, lat, lng float64, taskTemplateID int) ([]AvailableDate, error) {
+ payload, err := t.Query(ctx, "page.book.schedule", struct {
+ CategoryID int `json:"categoryId"`
+ InviteeID int `json:"inviteeId"`
+ Locale string `json:"locale"`
+ Location struct {
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+ } `json:"location"`
+ TaskTemplateID int `json:"taskTemplateId"`
+ }{
+ CategoryID: categoryID,
+ InviteeID: inviteeID,
+ Locale: locale,
+ Location: struct {
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+ }{
+ Lat: lat,
+ Lng: lng,
+ },
+ TaskTemplateID: taskTemplateID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return parseSchedulePayload(payload)
+}
+
+// Exact single-branch member fields are finalized during the authorized real hire+cancel round-trip; taskId is the best-effort guess.
+// CancelTask cancels a single TaskRabbit booking. Verified live 2026-07-03:
+// page.tasks.cancelTask with type "single" requires jobId (details.id),
+// rabbitId (taskers[0].id), and reason.
+func (t *Client) CancelTask(ctx context.Context, jobID, rabbitID int, reason string, csrfToken string) (json.RawMessage, error) {
+ if reason == "" {
+ reason = "Plans changed, no longer need the help"
+ }
+ return t.Mutation(ctx, "page.tasks.cancelTask", map[string]any{
+ "type": "single",
+ "jobId": jobID,
+ "rabbitId": rabbitID,
+ "reason": reason,
+ }, csrfToken)
+}
+
+func unwrapTRPC(proc string, body []byte) (json.RawMessage, error) {
+ var batch []json.RawMessage
+ if err := json.Unmarshal(body, &batch); err != nil {
+ return nil, fmt.Errorf("taskrabbit %s: decode tRPC batch: %w", proc, err)
+ }
+ if len(batch) == 0 {
+ return nil, fmt.Errorf("taskrabbit %s: empty tRPC response", proc)
+ }
+
+ var item struct {
+ Result *struct {
+ Data struct {
+ JSON json.RawMessage `json:"json"`
+ } `json:"data"`
+ } `json:"result"`
+ Error *struct {
+ JSON struct {
+ Message string `json:"message"`
+ Data struct {
+ Code string `json:"code"`
+ } `json:"data"`
+ } `json:"json"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(batch[0], &item); err != nil {
+ return nil, fmt.Errorf("taskrabbit %s: decode tRPC item: %w", proc, err)
+ }
+ if item.Error != nil {
+ return nil, &TRPCError{
+ Proc: proc,
+ Code: item.Error.JSON.Data.Code,
+ Message: item.Error.JSON.Message,
+ }
+ }
+ if item.Result == nil || len(item.Result.Data.JSON) == 0 {
+ return nil, fmt.Errorf("taskrabbit %s: missing tRPC result.data.json", proc)
+ }
+ return cloneRaw(item.Result.Data.JSON), nil
+}
+
+func parseListTasksPayload(payload json.RawMessage) ([]Booking, error) {
+ var decoded struct {
+ BFF struct {
+ Items []json.RawMessage `json:"items"`
+ } `json:"bff"`
+ }
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return nil, fmt.Errorf("taskrabbit page.tasks.list: decode payload: %w", err)
+ }
+ bookings := make([]Booking, 0, len(decoded.BFF.Items))
+ for _, raw := range decoded.BFF.Items {
+ booking, err := parseBooking(raw)
+ if err != nil {
+ return nil, err
+ }
+ bookings = append(bookings, booking)
+ }
+ return bookings, nil
+}
+
+func parseBooking(raw json.RawMessage) (Booking, error) {
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ return Booking{}, fmt.Errorf("taskrabbit page.tasks.list: decode item: %w", err)
+ }
+ booking := Booking{
+ ID: stringFromRaw(fields["id"]),
+ Status: stringFromRaw(fields["status"]),
+ Taskers: firstRaw(fields, "taskers", "tasker", "rabbits"),
+ FutureAppointments: firstRaw(fields, "futureAppointments", "future_appointments"),
+ Raw: cloneRaw(raw),
+ }
+ if booking.ID == "" {
+ booking.ID = idFromDetails(fields["details"])
+ }
+ // jobId (details.id) and rabbitId (taskers[0].id) power cancelTask.
+ if d := fields["details"]; len(d) > 0 {
+ var det struct {
+ ID int `json:"id"`
+ }
+ if json.Unmarshal(d, &det) == nil {
+ booking.JobID = det.ID
+ }
+ }
+ if booking.JobID == 0 {
+ if n, err := strconv.Atoi(strings.TrimSpace(booking.ID)); err == nil {
+ booking.JobID = n
+ }
+ }
+ if t := booking.Taskers; len(t) > 0 {
+ var arr []struct {
+ ID int `json:"id"`
+ FirstName string `json:"firstName"`
+ Name string `json:"name"`
+ }
+ if json.Unmarshal(t, &arr) == nil && len(arr) > 0 {
+ booking.RabbitID = arr[0].ID
+ if arr[0].FirstName != "" {
+ booking.TaskerName = arr[0].FirstName
+ } else {
+ booking.TaskerName = arr[0].Name
+ }
+ }
+ }
+ if a := fields["appointment"]; len(a) > 0 {
+ var appt struct {
+ LongDateText string `json:"longDateText"`
+ TimeText string `json:"timeText"`
+ }
+ if json.Unmarshal(a, &appt) == nil {
+ booking.Appointment = strings.TrimSpace(appt.LongDateText + " " + appt.TimeText)
+ }
+ }
+ return booking, nil
+}
+
+func parseRecommendationsPayload(payload json.RawMessage) ([]Tasker, Histogram, error) {
+ taskers, histogram, _, err := parseRecommendationsPayloadWithID(payload)
+ return taskers, histogram, err
+}
+
+func parseRecommendationsPayloadWithID(payload json.RawMessage) ([]Tasker, Histogram, string, error) {
+ var decoded struct {
+ BFF struct {
+ Recommendations []json.RawMessage `json:"recommendations"`
+ Histogram Histogram `json:"histogram"`
+ RecommendationID string `json:"recommendation_id"`
+ } `json:"bff"`
+ }
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return nil, Histogram{}, "", fmt.Errorf("taskrabbit page.book.recommendations: decode payload: %w", err)
+ }
+ taskers := make([]Tasker, 0, len(decoded.BFF.Recommendations))
+ for _, raw := range decoded.BFF.Recommendations {
+ var tasker Tasker
+ if err := json.Unmarshal(raw, &tasker); err != nil {
+ return nil, Histogram{}, "", fmt.Errorf("taskrabbit page.book.recommendations: decode tasker: %w", err)
+ }
+ tasker.Raw = cloneRaw(raw)
+ taskers = append(taskers, tasker)
+ }
+ return taskers, decoded.BFF.Histogram, decoded.BFF.RecommendationID, nil
+}
+
+func parseSchedulePayload(payload json.RawMessage) ([]AvailableDate, error) {
+ var decoded struct {
+ BFF struct {
+ AvailableDates []AvailableDate `json:"availableDates"`
+ } `json:"bff"`
+ }
+ if err := json.Unmarshal(payload, &decoded); err != nil {
+ return nil, fmt.Errorf("taskrabbit page.book.schedule: decode payload: %w", err)
+ }
+ if decoded.BFF.AvailableDates == nil {
+ return make([]AvailableDate, 0), nil
+ }
+ return decoded.BFF.AvailableDates, nil
+}
+
+func trpcInputEnvelope(input any) map[string]map[string]any {
+ return map[string]map[string]any{
+ "0": {
+ "json": input,
+ },
+ }
+}
+
+func idFromDetails(raw json.RawMessage) string {
+ if id := stringFromRaw(raw); id != "" {
+ return id
+ }
+ var details map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &details); err != nil {
+ return ""
+ }
+ for _, key := range []string{"id", "taskId", "task_id", "guid", "taskGuid", "task_guid"} {
+ if id := stringFromRaw(details[key]); id != "" {
+ return id
+ }
+ }
+ return ""
+}
+
+func stringFromRaw(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ return s
+ }
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ var value any
+ if err := decoder.Decode(&value); err != nil {
+ return ""
+ }
+ switch v := value.(type) {
+ case json.Number:
+ return v.String()
+ case bool:
+ return fmt.Sprint(v)
+ default:
+ return ""
+ }
+}
+
+func intFromRaw(raw json.RawMessage) int {
+ if len(raw) == 0 {
+ return 0
+ }
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ var value any
+ if err := decoder.Decode(&value); err != nil {
+ return 0
+ }
+ switch v := value.(type) {
+ case json.Number:
+ if n, err := v.Int64(); err == nil {
+ return int(n)
+ }
+ if f, err := strconv.ParseFloat(v.String(), 64); err == nil {
+ return int(f)
+ }
+ case string:
+ if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
+ return n
+ }
+ case float64:
+ return int(v)
+ }
+ return 0
+}
+
+func firstRaw(fields map[string]json.RawMessage, keys ...string) json.RawMessage {
+ for _, key := range keys {
+ if raw, ok := fields[key]; ok {
+ return cloneRaw(raw)
+ }
+ }
+ return nil
+}
+
+func cloneRaw(raw json.RawMessage) json.RawMessage {
+ if raw == nil {
+ return nil
+ }
+ out := make(json.RawMessage, len(raw))
+ copy(out, raw)
+ return out
+}
+
+func truncateRunes(s string, limit int) string {
+ if limit <= 0 {
+ return ""
+ }
+ runes := []rune(s)
+ if len(runes) <= limit {
+ return s
+ }
+ return string(runes[:limit]) + "..."
+}
diff --git a/library/productivity/human-goat/internal/source/taskrabbit/trpc_test.go b/library/productivity/human-goat/internal/source/taskrabbit/trpc_test.go
new file mode 100644
index 0000000000..aa3962bcc8
--- /dev/null
+++ b/library/productivity/human-goat/internal/source/taskrabbit/trpc_test.go
@@ -0,0 +1,82 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+
+package taskrabbit
+
+import (
+ "errors"
+ "math"
+ "testing"
+)
+
+func TestUnwrapTRPCSuccess(t *testing.T) {
+ got, err := unwrapTRPC("page.book.recommendations", []byte(`[{"result":{"data":{"json":{"bff":{"recommendations":[]}}}}}]`))
+ if err != nil {
+ t.Fatalf("unwrapTRPC() error = %v", err)
+ }
+ if string(got) != `{"bff":{"recommendations":[]}}` {
+ t.Fatalf("unwrapTRPC() = %s, want inner json", got)
+ }
+}
+
+func TestUnwrapTRPCError(t *testing.T) {
+ _, err := unwrapTRPC("page.book.recommendations", []byte(`[{"error":{"json":{"message":"[{\"path\":[\"locale\"]}]","data":{"code":"BAD_REQUEST"}}}}]`))
+ if err == nil {
+ t.Fatal("unwrapTRPC() error = nil, want TRPCError")
+ }
+ var trpcErr *TRPCError
+ if !errors.As(err, &trpcErr) {
+ t.Fatalf("unwrapTRPC() error = %T, want *TRPCError", err)
+ }
+ if trpcErr.Code != "BAD_REQUEST" {
+ t.Fatalf("TRPCError.Code = %q, want %q", trpcErr.Code, "BAD_REQUEST")
+ }
+}
+
+func TestParseRecommendationsPayload(t *testing.T) {
+ taskers, histogram, err := parseRecommendationsPayload([]byte(`{
+ "bff": {
+ "recommendations": [
+ {
+ "id": "tasker-1",
+ "user_id": 123,
+ "first_name": "Ada",
+ "display_name": "Ada L.",
+ "poster_hourly_rate_cents": 3333,
+ "poster_hourly_rate_currency": "USD",
+ "rabbit_rating": "100%",
+ "category_family_average_star_rating": 5.0,
+ "rabbit_number_of_reviews": 17,
+ "category_invoices_count": 8,
+ "hours_worked": 42.5,
+ "elite": true,
+ "reliability_rate": "99%",
+ "next_available_at": "2026-07-04T09:00:00Z",
+ "is_favorite": true,
+ "past_tasker": false,
+ "two_hour_minimum_required_display": "2 hr minimum"
+ }
+ ],
+ "histogram": {
+ "minimum_price_cents": 2500,
+ "median_price_cents": 3333,
+ "maximum_price_cents": 5000,
+ "currency_code": "USD"
+ }
+ }
+ }`))
+ if err != nil {
+ t.Fatalf("parseRecommendationsPayload() error = %v", err)
+ }
+ if len(taskers) != 1 {
+ t.Fatalf("len(taskers) = %d, want 1", len(taskers))
+ }
+ if taskers[0].PosterHourlyRateCents != 3333 {
+ t.Fatalf("PosterHourlyRateCents = %d, want 3333", taskers[0].PosterHourlyRateCents)
+ }
+ if math.Abs(taskers[0].RabbitRating-5.0) > 0.0001 {
+ t.Fatalf("RabbitRating = %v, want 5.0", taskers[0].RabbitRating)
+ }
+ if histogram.MedianPriceCents != 3333 {
+ t.Fatalf("MedianPriceCents = %d, want 3333", histogram.MedianPriceCents)
+ }
+}
diff --git a/library/productivity/human-goat/internal/store/candidates.go b/library/productivity/human-goat/internal/store/candidates.go
new file mode 100644
index 0000000000..dc0412ba89
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/candidates.go
@@ -0,0 +1,418 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// This file owns the learn_candidates lifecycle: auto-captured
+// improvement candidates awaiting explicit judgment. Candidates are
+// structurally quarantined — nothing here reads or writes the verified
+// learning rows; only an explicit confirm materializes a payload, and
+// that materialization goes through the existing playbook machinery
+// (UpsertPlaybook / AppendPlaybookNotes in playbooks.go).
+//
+// State machine:
+//
+// open -> confirmed ConfirmCandidate (the caller materializes
+// the payload per class before confirming)
+// open -> rejected RejectCandidate. The rejected row IS the
+// tombstone: DeriveCandidate no-ops on its
+// derivation signature from then on.
+// open -> expired ExpireCandidates after the unseen TTL.
+// expired -> open A fresh sighting via DeriveCandidate.
+// confirmed -> rejected playbook_candidate only; the rollback is a
+// clean delete of the materialized playbook
+// row. Confirmed flag corrections have no
+// reject path in v1 (repair goes through
+// `playbook amend`).
+
+// Candidate classes and statuses mirror the CHECK constraints on the
+// learn_candidates table created by store.go at schema v9.
+const (
+ CandidateClassFlagAlias = "flag_alias"
+ CandidateClassPlaybookCandidate = "playbook_candidate"
+
+ CandidateStatusOpen = "open"
+ CandidateStatusConfirmed = "confirmed"
+ CandidateStatusRejected = "rejected"
+ CandidateStatusExpired = "expired"
+)
+
+// DefaultCandidateTTL is how long an open candidate may go unseen
+// before ExpireCandidates marks it expired.
+const DefaultCandidateTTL = 30 * 24 * time.Hour
+
+// candidateTimeFormat keeps candidate timestamps as RFC3339 UTC
+// strings; fixed-width and lexicographically sortable, so the expiry
+// cutoff comparison can run inside SQL against the TEXT columns.
+const candidateTimeFormat = time.RFC3339
+
+// defaultCandidateListLimit caps ListCandidates when the caller
+// supplies no explicit limit, so the recall envelope and the control
+// commands stay bounded on a noisy store.
+const defaultCandidateListLimit = 50
+
+// ErrConfirmedFlagAliasReject is returned by RejectCandidate for a
+// confirmed flag-correction candidate: once its guidance is appended
+// to the family playbook there is no clean row-level rollback, so v1
+// has no confirmed-to-rejected transition for this class. The repair
+// path is amending the playbook notes.
+var ErrConfirmedFlagAliasReject = errors.New("confirmed flag_alias candidates have no reject path; repair the playbook notes instead")
+
+// CandidateRow is one row in the learn_candidates table.
+type CandidateRow struct {
+ ID int64 `json:"id"`
+ Class string `json:"class"`
+ Payload string `json:"payload"`
+ DerivationSignature string `json:"derivation_signature"`
+ Sightings int `json:"sightings"`
+ Status string `json:"status"`
+ QueryFamily string `json:"query_family,omitempty"`
+ CommandPath string `json:"command_path,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ LastSeenAt time.Time `json:"last_seen_at"`
+}
+
+// ListCandidatesFilter narrows ListCandidates. Zero values mean "no
+// filter" for Status/Class/QueryFamily; Limit <= 0 applies the
+// default cap.
+type ListCandidatesFilter struct {
+ Status string
+ Class string
+ QueryFamily string
+ Limit int
+}
+
+// DeriveCandidate records one observation of a derivable improvement.
+// A new derivation signature inserts an open row at sightings 1; a
+// repeat signature bumps sightings and refreshes last_seen_at; a
+// sighting on an expired row reopens it. A rejected signature is a
+// tombstone: the observation is dropped entirely (no sightings bump,
+// no timestamp refresh) and the rejected row is returned so callers
+// can tell why nothing changed.
+//
+// The write is a single upsert statement so two processes racing the
+// same signature resolve to exactly one insert plus one bump.
+// Returns the row as stored and whether this call inserted it.
+func (s *Store) DeriveCandidate(class, payload, signature, queryFamily, commandPath string) (CandidateRow, bool, error) {
+ if class != CandidateClassFlagAlias && class != CandidateClassPlaybookCandidate {
+ return CandidateRow{}, false, fmt.Errorf("derive candidate: unknown class %q", class)
+ }
+ if strings.TrimSpace(payload) == "" {
+ return CandidateRow{}, false, fmt.Errorf("derive candidate: payload is required")
+ }
+ if strings.TrimSpace(signature) == "" {
+ return CandidateRow{}, false, fmt.Errorf("derive candidate: derivation signature is required")
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ now := time.Now().UTC().Format(candidateTimeFormat)
+ res, err := s.db.Exec(`INSERT INTO learn_candidates
+ (class, payload, derivation_signature, sightings, status, query_family, command_path, created_at, updated_at, last_seen_at)
+ VALUES (?, ?, ?, 1, 'open', ?, ?, ?, ?, ?)
+ ON CONFLICT(derivation_signature) DO UPDATE SET
+ sightings = sightings + 1,
+ status = CASE WHEN learn_candidates.status = 'expired' THEN 'open' ELSE learn_candidates.status END,
+ updated_at = excluded.updated_at,
+ last_seen_at = excluded.last_seen_at
+ WHERE learn_candidates.status != 'rejected'`,
+ class, payload, signature, queryFamily, commandPath, now, now, now,
+ )
+ if err != nil {
+ return CandidateRow{}, false, fmt.Errorf("derive candidate: %w", err)
+ }
+ affected, _ := res.RowsAffected()
+
+ row, ok, err := s.getCandidateBySignature(signature)
+ if err != nil {
+ return CandidateRow{}, false, fmt.Errorf("derive candidate: %w", err)
+ }
+ if !ok {
+ return CandidateRow{}, false, fmt.Errorf("derive candidate: row for signature missing after upsert")
+ }
+ // A bump always leaves sightings >= 2 and a tombstone no-op leaves
+ // affected == 0, so this call inserted the row exactly when the
+ // statement changed one row that now carries a single sighting.
+ inserted := affected == 1 && row.Sightings == 1
+ return row, inserted, nil
+}
+
+// GetCandidate returns the row with the given id, or
+// (CandidateRow{}, false, nil) when nothing matches.
+func (s *Store) GetCandidate(id int64) (CandidateRow, bool, error) {
+ row, err := scanCandidate(s.db.QueryRow(
+ candidateSelectColumns+` FROM learn_candidates WHERE id = ?`, id,
+ ))
+ if err == sql.ErrNoRows {
+ return CandidateRow{}, false, nil
+ }
+ if err != nil {
+ return CandidateRow{}, false, fmt.Errorf("get candidate: %w", err)
+ }
+ return row, true, nil
+}
+
+func (s *Store) getCandidateBySignature(signature string) (CandidateRow, bool, error) {
+ row, err := scanCandidate(s.db.QueryRow(
+ candidateSelectColumns+` FROM learn_candidates WHERE derivation_signature = ?`, signature,
+ ))
+ if err == sql.ErrNoRows {
+ return CandidateRow{}, false, nil
+ }
+ if err != nil {
+ return CandidateRow{}, false, err
+ }
+ return row, true, nil
+}
+
+// ListCandidates returns rows matching the filter, highest-sighting
+// first, capped at the filter limit (default cap when unset).
+func (s *Store) ListCandidates(f ListCandidatesFilter) ([]CandidateRow, error) {
+ query := candidateSelectColumns + ` FROM learn_candidates WHERE 1=1`
+ var args []any
+ if strings.TrimSpace(f.Status) != "" {
+ query += ` AND status = ?`
+ args = append(args, f.Status)
+ }
+ if strings.TrimSpace(f.Class) != "" {
+ query += ` AND class = ?`
+ args = append(args, f.Class)
+ }
+ if strings.TrimSpace(f.QueryFamily) != "" {
+ query += ` AND query_family = ?`
+ args = append(args, f.QueryFamily)
+ }
+ limit := f.Limit
+ if limit <= 0 {
+ limit = defaultCandidateListLimit
+ }
+ query += ` ORDER BY sightings DESC, last_seen_at DESC LIMIT ?`
+ args = append(args, limit)
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("list candidates: %w", err)
+ }
+ defer rows.Close()
+ var out []CandidateRow
+ for rows.Next() {
+ row, err := scanCandidate(rows)
+ if err != nil {
+ return nil, fmt.Errorf("scan candidate: %w", err)
+ }
+ out = append(out, row)
+ }
+ return out, rows.Err()
+}
+
+// ConfirmCandidate marks an open candidate confirmed. Materialization
+// of the payload is the caller's job (per class, through the playbook
+// machinery) and happens before this state change so a failed
+// materialization leaves the candidate open and retryable.
+func (s *Store) ConfirmCandidate(id int64) (CandidateRow, error) {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return CandidateRow{}, err
+ }
+ defer tx.Rollback()
+
+ row, err := scanCandidate(tx.QueryRow(
+ candidateSelectColumns+` FROM learn_candidates WHERE id = ?`, id,
+ ))
+ if err == sql.ErrNoRows {
+ return CandidateRow{}, fmt.Errorf("confirm candidate: no candidate with id %d", id)
+ }
+ if err != nil {
+ return CandidateRow{}, fmt.Errorf("confirm candidate: %w", err)
+ }
+ if row.Status != CandidateStatusOpen {
+ return CandidateRow{}, fmt.Errorf("confirm candidate: candidate %d is %s; only open candidates can be confirmed", id, row.Status)
+ }
+
+ now := time.Now().UTC()
+ if _, err := tx.Exec(
+ `UPDATE learn_candidates SET status = ?, updated_at = ? WHERE id = ?`,
+ CandidateStatusConfirmed, now.Format(candidateTimeFormat), id,
+ ); err != nil {
+ return CandidateRow{}, fmt.Errorf("confirm candidate: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return CandidateRow{}, err
+ }
+ row.Status = CandidateStatusConfirmed
+ row.UpdatedAt = now
+ return row, nil
+}
+
+// RejectCandidate marks a candidate rejected. The rejected row itself
+// is the tombstone on its derivation signature: DeriveCandidate
+// no-ops on that signature until the tombstone is purged.
+//
+// Open and expired rows of either class can be rejected. A confirmed
+// playbook_candidate can be rejected too, and the rollback is a clean
+// delete of the playbook row materialized for its query family. A
+// confirmed flag_alias returns ErrConfirmedFlagAliasReject.
+func (s *Store) RejectCandidate(id int64) (CandidateRow, error) {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return CandidateRow{}, err
+ }
+ defer tx.Rollback()
+
+ row, err := scanCandidate(tx.QueryRow(
+ candidateSelectColumns+` FROM learn_candidates WHERE id = ?`, id,
+ ))
+ if err == sql.ErrNoRows {
+ return CandidateRow{}, fmt.Errorf("reject candidate: no candidate with id %d", id)
+ }
+ if err != nil {
+ return CandidateRow{}, fmt.Errorf("reject candidate: %w", err)
+ }
+ if row.Status == CandidateStatusRejected {
+ return CandidateRow{}, fmt.Errorf("reject candidate: candidate %d is already rejected", id)
+ }
+ if row.Status == CandidateStatusConfirmed {
+ if row.Class == CandidateClassFlagAlias {
+ return CandidateRow{}, fmt.Errorf("reject candidate %d: %w", id, ErrConfirmedFlagAliasReject)
+ }
+ // Rollback the materialized playbook row inside the same
+ // transaction so the tombstone and the delete land together.
+ if family := candidateFamily(row); family != "" {
+ if _, err := tx.Exec(
+ `DELETE FROM learning_playbooks WHERE query_family = ?`, family,
+ ); err != nil {
+ return CandidateRow{}, fmt.Errorf("reject candidate: rollback playbook: %w", err)
+ }
+ }
+ }
+
+ now := time.Now().UTC()
+ if _, err := tx.Exec(
+ `UPDATE learn_candidates SET status = ?, updated_at = ? WHERE id = ?`,
+ CandidateStatusRejected, now.Format(candidateTimeFormat), id,
+ ); err != nil {
+ return CandidateRow{}, fmt.Errorf("reject candidate: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return CandidateRow{}, err
+ }
+ row.Status = CandidateStatusRejected
+ row.UpdatedAt = now
+ return row, nil
+}
+
+// ExpireCandidates marks open rows unseen for longer than ttl as
+// expired and returns how many it marked. ttl <= 0 applies
+// DefaultCandidateTTL. A later sighting on an expired signature
+// reopens the row via DeriveCandidate.
+func (s *Store) ExpireCandidates(ttl time.Duration) (int64, error) {
+ if ttl <= 0 {
+ ttl = DefaultCandidateTTL
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ now := time.Now().UTC()
+ cutoff := now.Add(-ttl).Format(candidateTimeFormat)
+ res, err := s.db.Exec(
+ `UPDATE learn_candidates SET status = ?, updated_at = ?
+ WHERE status = ? AND last_seen_at < ?`,
+ CandidateStatusExpired, now.Format(candidateTimeFormat),
+ CandidateStatusOpen, cutoff,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("expire candidates: %w", err)
+ }
+ return res.RowsAffected()
+}
+
+// PurgeCandidates deletes settled candidate rows: expired and
+// confirmed rows always (their materialized artifacts, if any, stay);
+// rejected signature tombstones only when includeTombstones is set,
+// which re-allows derivation of those signatures. Open rows are never
+// purged. Returns how many rows were deleted.
+func (s *Store) PurgeCandidates(includeTombstones bool) (int64, error) {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ statuses := `(?, ?)`
+ args := []any{CandidateStatusExpired, CandidateStatusConfirmed}
+ if includeTombstones {
+ statuses = `(?, ?, ?)`
+ args = append(args, CandidateStatusRejected)
+ }
+ res, err := s.db.Exec(
+ `DELETE FROM learn_candidates WHERE status IN `+statuses, args...,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("purge candidates: %w", err)
+ }
+ return res.RowsAffected()
+}
+
+// candidateFamily resolves the query-family anchor for a candidate:
+// the row column when set, else the payload's query_family field.
+func candidateFamily(row CandidateRow) string {
+ if strings.TrimSpace(row.QueryFamily) != "" {
+ return strings.TrimSpace(row.QueryFamily)
+ }
+ var p struct {
+ QueryFamily string `json:"query_family"`
+ }
+ if err := json.Unmarshal([]byte(row.Payload), &p); err != nil {
+ return ""
+ }
+ return strings.TrimSpace(p.QueryFamily)
+}
+
+const candidateSelectColumns = `SELECT id, class, payload, derivation_signature, sightings, status,
+ COALESCE(query_family, ''), COALESCE(command_path, ''), created_at, updated_at, last_seen_at`
+
+// rowScanner covers both *sql.Row and *sql.Rows for scanCandidate.
+type rowScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanCandidate(r rowScanner) (CandidateRow, error) {
+ var row CandidateRow
+ var created, updated, lastSeen string
+ if err := r.Scan(
+ &row.ID, &row.Class, &row.Payload, &row.DerivationSignature,
+ &row.Sightings, &row.Status, &row.QueryFamily, &row.CommandPath,
+ &created, &updated, &lastSeen,
+ ); err != nil {
+ return CandidateRow{}, err
+ }
+ row.CreatedAt = parseCandidateTime(created)
+ row.UpdatedAt = parseCandidateTime(updated)
+ row.LastSeenAt = parseCandidateTime(lastSeen)
+ return row, nil
+}
+
+// parseCandidateTime is tolerant: a hand-edited or foreign timestamp
+// yields the zero time instead of failing the whole scan.
+func parseCandidateTime(v string) time.Time {
+ t, err := time.Parse(candidateTimeFormat, v)
+ if err != nil {
+ return time.Time{}
+ }
+ return t
+}
diff --git a/library/productivity/human-goat/internal/store/candidates_test.go b/library/productivity/human-goat/internal/store/candidates_test.go
new file mode 100644
index 0000000000..191b0bfa67
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/candidates_test.go
@@ -0,0 +1,426 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+func openTestCandidatesStore(t *testing.T) *Store {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "candidates.db")
+ s, err := OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func countCandidateRows(t *testing.T, s *Store) int {
+ t.Helper()
+ var n int
+ if err := s.DB().QueryRow(`SELECT COUNT(*) FROM learn_candidates`).Scan(&n); err != nil {
+ t.Fatalf("count candidates: %v", err)
+ }
+ return n
+}
+
+// backdateCandidateLastSeen pushes a candidate's last_seen_at into the
+// past so TTL expiry can be exercised without waiting.
+func backdateCandidateLastSeen(t *testing.T, s *Store, id int64, age time.Duration) {
+ t.Helper()
+ old := time.Now().UTC().Add(-age).Format(candidateTimeFormat)
+ if _, err := s.DB().Exec(
+ `UPDATE learn_candidates SET last_seen_at = ? WHERE id = ?`, old, id,
+ ); err != nil {
+ t.Fatalf("backdate candidate %d: %v", id, err)
+ }
+}
+
+func TestDeriveCandidate_InsertCreatesOpenRow(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ row, inserted, err := s.DeriveCandidate(
+ CandidateClassFlagAlias,
+ `{"failed_flag":"--date","corrected_flag":"--dates","command_path":"items list"}`,
+ "sig-flag-1", "fam-items", "items list",
+ )
+ if err != nil {
+ t.Fatalf("derive: %v", err)
+ }
+ if !inserted {
+ t.Errorf("first derivation should report inserted=true")
+ }
+ if row.ID <= 0 {
+ t.Errorf("want positive id, got %d", row.ID)
+ }
+ if row.Status != CandidateStatusOpen {
+ t.Errorf("want status open, got %q", row.Status)
+ }
+ if row.Sightings != 1 {
+ t.Errorf("want sightings 1, got %d", row.Sightings)
+ }
+ if row.Class != CandidateClassFlagAlias {
+ t.Errorf("want class flag_alias, got %q", row.Class)
+ }
+ if row.QueryFamily != "fam-items" || row.CommandPath != "items list" {
+ t.Errorf("anchors mismatch: family=%q path=%q", row.QueryFamily, row.CommandPath)
+ }
+ if !strings.Contains(row.Payload, "--dates") {
+ t.Errorf("payload should round-trip, got %q", row.Payload)
+ }
+ if row.CreatedAt.IsZero() || row.LastSeenAt.IsZero() {
+ t.Errorf("timestamps should parse: created=%v last_seen=%v", row.CreatedAt, row.LastSeenAt)
+ }
+}
+
+func TestDeriveCandidate_SameSignatureBumpsSightingsNotRows(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ first, inserted, err := s.DeriveCandidate(
+ CandidateClassPlaybookCandidate, `{"notes_text":"try the list command first"}`,
+ "sig-pb-1", "fam-a", "",
+ )
+ if err != nil || !inserted {
+ t.Fatalf("first derive: err=%v inserted=%v", err, inserted)
+ }
+ second, inserted, err := s.DeriveCandidate(
+ CandidateClassPlaybookCandidate, `{"notes_text":"try the list command first"}`,
+ "sig-pb-1", "fam-a", "",
+ )
+ if err != nil {
+ t.Fatalf("second derive: %v", err)
+ }
+ if inserted {
+ t.Errorf("repeat signature must bump, not insert")
+ }
+ if second.ID != first.ID {
+ t.Errorf("repeat signature must hit the same row: %d vs %d", second.ID, first.ID)
+ }
+ if second.Sightings != 2 {
+ t.Errorf("want sightings 2 after bump, got %d", second.Sightings)
+ }
+ if got := countCandidateRows(t, s); got != 1 {
+ t.Errorf("want 1 row, got %d", got)
+ }
+}
+
+func TestDeriveCandidate_UnknownClassErrors(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ if _, _, err := s.DeriveCandidate("other", `{}`, "sig-x", "", ""); err == nil {
+ t.Fatal("unknown class must error")
+ }
+}
+
+func TestDeriveCandidate_ConcurrentSameSignature(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ const workers = 2
+ insertedCh := make(chan bool, workers)
+ var wg sync.WaitGroup
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, inserted, err := s.DeriveCandidate(
+ CandidateClassFlagAlias,
+ `{"failed_flag":"--from","corrected_flag":"--since"}`,
+ "sig-race", "", "records list",
+ )
+ if err != nil {
+ t.Errorf("concurrent derive: %v", err)
+ insertedCh <- false
+ return
+ }
+ insertedCh <- inserted
+ }()
+ }
+ wg.Wait()
+ close(insertedCh)
+
+ inserts := 0
+ for ins := range insertedCh {
+ if ins {
+ inserts++
+ }
+ }
+ if inserts != 1 {
+ t.Errorf("racing derives must yield exactly one insert, got %d", inserts)
+ }
+ row, ok, err := s.getCandidateBySignature("sig-race")
+ if err != nil || !ok {
+ t.Fatalf("get by signature: err=%v ok=%v", err, ok)
+ }
+ if row.Sightings != workers {
+ t.Errorf("want sightings %d (one insert + one bump), got %d", workers, row.Sightings)
+ }
+ if got := countCandidateRows(t, s); got != 1 {
+ t.Errorf("want 1 row after race, got %d", got)
+ }
+}
+
+func TestConfirmCandidate_OpenOnly(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ row, _, err := s.DeriveCandidate(
+ CandidateClassPlaybookCandidate, `{"notes_text":"guidance"}`, "sig-c1", "fam-c", "",
+ )
+ if err != nil {
+ t.Fatalf("derive: %v", err)
+ }
+
+ confirmed, err := s.ConfirmCandidate(row.ID)
+ if err != nil {
+ t.Fatalf("confirm: %v", err)
+ }
+ if confirmed.Status != CandidateStatusConfirmed {
+ t.Errorf("want confirmed, got %q", confirmed.Status)
+ }
+ if _, err := s.ConfirmCandidate(row.ID); err == nil {
+ t.Error("confirming a non-open candidate must error")
+ }
+ if _, err := s.ConfirmCandidate(999999); err == nil {
+ t.Error("confirming a missing id must error")
+ }
+}
+
+func TestRejectCandidate_TombstonesSignature(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ row, _, err := s.DeriveCandidate(
+ CandidateClassFlagAlias, `{"failed_flag":"--kind","corrected_flag":"--type"}`,
+ "sig-r1", "", "things list",
+ )
+ if err != nil {
+ t.Fatalf("derive: %v", err)
+ }
+ rejected, err := s.RejectCandidate(row.ID)
+ if err != nil {
+ t.Fatalf("reject: %v", err)
+ }
+ if rejected.Status != CandidateStatusRejected {
+ t.Errorf("want rejected, got %q", rejected.Status)
+ }
+
+ // The identical failure re-derives nothing: the rejected row is
+ // the tombstone and the observation is dropped.
+ again, inserted, err := s.DeriveCandidate(
+ CandidateClassFlagAlias, `{"failed_flag":"--kind","corrected_flag":"--type"}`,
+ "sig-r1", "", "things list",
+ )
+ if err != nil {
+ t.Fatalf("re-derive on tombstone: %v", err)
+ }
+ if inserted {
+ t.Error("tombstoned signature must not re-insert")
+ }
+ if again.Status != CandidateStatusRejected {
+ t.Errorf("tombstoned row must stay rejected, got %q", again.Status)
+ }
+ if again.Sightings != 1 {
+ t.Errorf("tombstoned row must not accrue sightings, got %d", again.Sightings)
+ }
+ if got := countCandidateRows(t, s); got != 1 {
+ t.Errorf("want 1 row, got %d", got)
+ }
+ if _, err := s.RejectCandidate(row.ID); err == nil {
+ t.Error("rejecting an already-rejected candidate must error")
+ }
+}
+
+func TestRejectCandidate_ConfirmedPlaybookRollbackDeletesMaterializedRow(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ row, _, err := s.DeriveCandidate(
+ CandidateClassPlaybookCandidate,
+ `{"playbook_json":"{\"steps\":[{\"cmd\":\"records list --limit 5\"}]}","notes_text":"start narrow"}`,
+ "sig-pbroll", "fam-roll", "",
+ )
+ if err != nil {
+ t.Fatalf("derive: %v", err)
+ }
+ // Materialize (as confirm's caller does) then confirm.
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-roll",
+ PlaybookJSON: `{"steps":[{"cmd":"records list --limit 5"}]}`,
+ NotesText: "start narrow",
+ }); err != nil {
+ t.Fatalf("materialize: %v", err)
+ }
+ if _, err := s.ConfirmCandidate(row.ID); err != nil {
+ t.Fatalf("confirm: %v", err)
+ }
+
+ rejected, err := s.RejectCandidate(row.ID)
+ if err != nil {
+ t.Fatalf("reject confirmed playbook candidate: %v", err)
+ }
+ if rejected.Status != CandidateStatusRejected {
+ t.Errorf("want rejected, got %q", rejected.Status)
+ }
+ if _, ok, err := s.GetPlaybookByFamily("fam-roll"); err != nil || ok {
+ t.Errorf("rollback must delete the materialized playbook row: ok=%v err=%v", ok, err)
+ }
+}
+
+func TestRejectCandidate_ConfirmedFlagAliasRefused(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ row, _, err := s.DeriveCandidate(
+ CandidateClassFlagAlias, `{"failed_flag":"--day","corrected_flag":"--date"}`,
+ "sig-fa-conf", "fam-fa", "records list",
+ )
+ if err != nil {
+ t.Fatalf("derive: %v", err)
+ }
+ if _, err := s.ConfirmCandidate(row.ID); err != nil {
+ t.Fatalf("confirm: %v", err)
+ }
+
+ _, err = s.RejectCandidate(row.ID)
+ if !errors.Is(err, ErrConfirmedFlagAliasReject) {
+ t.Fatalf("want ErrConfirmedFlagAliasReject, got %v", err)
+ }
+ got, ok, err := s.GetCandidate(row.ID)
+ if err != nil || !ok {
+ t.Fatalf("get: err=%v ok=%v", err, ok)
+ }
+ if got.Status != CandidateStatusConfirmed {
+ t.Errorf("refused reject must leave the row confirmed, got %q", got.Status)
+ }
+}
+
+func TestExpireCandidates_TTLExcludesFromOpenAndSightingReopens(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ row, _, err := s.DeriveCandidate(
+ CandidateClassPlaybookCandidate, `{"notes_text":"old guidance"}`,
+ "sig-ttl", "fam-ttl", "",
+ )
+ if err != nil {
+ t.Fatalf("derive: %v", err)
+ }
+ backdateCandidateLastSeen(t, s, row.ID, 31*24*time.Hour)
+
+ n, err := s.ExpireCandidates(0) // 0 -> DefaultCandidateTTL (30 days)
+ if err != nil {
+ t.Fatalf("expire: %v", err)
+ }
+ if n != 1 {
+ t.Fatalf("want 1 expired, got %d", n)
+ }
+ open, err := s.ListCandidates(ListCandidatesFilter{Status: CandidateStatusOpen})
+ if err != nil {
+ t.Fatalf("list open: %v", err)
+ }
+ if len(open) != 0 {
+ t.Errorf("expired row must not list as open, got %d rows", len(open))
+ }
+
+ // A fresh sighting reopens the expired signature.
+ reopened, inserted, err := s.DeriveCandidate(
+ CandidateClassPlaybookCandidate, `{"notes_text":"old guidance"}`,
+ "sig-ttl", "fam-ttl", "",
+ )
+ if err != nil {
+ t.Fatalf("re-derive: %v", err)
+ }
+ if inserted {
+ t.Error("reopen must not insert a second row")
+ }
+ if reopened.Status != CandidateStatusOpen {
+ t.Errorf("want reopened status open, got %q", reopened.Status)
+ }
+ if reopened.Sightings != 2 {
+ t.Errorf("want sightings 2 after reopen, got %d", reopened.Sightings)
+ }
+}
+
+func TestPurgeCandidates_TombstonesOnlyOnRequest(t *testing.T) {
+ t.Parallel()
+ s := openTestCandidatesStore(t)
+
+ expiredRow, _, err := s.DeriveCandidate(
+ CandidateClassPlaybookCandidate, `{"notes_text":"stale"}`, "sig-purge-exp", "fam-p1", "",
+ )
+ if err != nil {
+ t.Fatalf("derive expired: %v", err)
+ }
+ backdateCandidateLastSeen(t, s, expiredRow.ID, 45*24*time.Hour)
+ if _, err := s.ExpireCandidates(0); err != nil {
+ t.Fatalf("expire: %v", err)
+ }
+
+ rejectedRow, _, err := s.DeriveCandidate(
+ CandidateClassFlagAlias, `{"failed_flag":"--a","corrected_flag":"--b"}`,
+ "sig-purge-rej", "", "items list",
+ )
+ if err != nil {
+ t.Fatalf("derive rejected: %v", err)
+ }
+ if _, err := s.RejectCandidate(rejectedRow.ID); err != nil {
+ t.Fatalf("reject: %v", err)
+ }
+
+ openRow, _, err := s.DeriveCandidate(
+ CandidateClassFlagAlias, `{"failed_flag":"--c","corrected_flag":"--d"}`,
+ "sig-purge-open", "", "items list",
+ )
+ if err != nil {
+ t.Fatalf("derive open: %v", err)
+ }
+
+ n, err := s.PurgeCandidates(false)
+ if err != nil {
+ t.Fatalf("purge: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("default purge should remove only the expired row, got %d", n)
+ }
+ if _, ok, _ := s.GetCandidate(rejectedRow.ID); !ok {
+ t.Error("default purge must keep rejected tombstones")
+ }
+ if _, ok, _ := s.GetCandidate(openRow.ID); !ok {
+ t.Error("purge must never remove open rows")
+ }
+
+ n, err = s.PurgeCandidates(true)
+ if err != nil {
+ t.Fatalf("purge tombstones: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("tombstone purge should remove the rejected row, got %d", n)
+ }
+
+ // With the tombstone gone the signature derives fresh again.
+ _, inserted, err := s.DeriveCandidate(
+ CandidateClassFlagAlias, `{"failed_flag":"--a","corrected_flag":"--b"}`,
+ "sig-purge-rej", "", "items list",
+ )
+ if err != nil {
+ t.Fatalf("re-derive after tombstone purge: %v", err)
+ }
+ if !inserted {
+ t.Error("purging the tombstone must re-allow derivation")
+ }
+}
diff --git a/library/productivity/human-goat/internal/store/events.go b/library/productivity/human-goat/internal/store/events.go
new file mode 100644
index 0000000000..97f688f4d0
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/events.go
@@ -0,0 +1,344 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "sync/atomic"
+ "time"
+)
+
+// This file owns the learn_events measurement layer: telemetry-class
+// rows the learn loop's command side records so `learnings stats` can
+// report whether the loop earns its keep on this machine. Local-only
+// by contract — nothing here ever leaves the SQLite store.
+//
+// Inserts are best-effort by caller contract: command code swallows
+// any returned error into teach.log and never fails the parent
+// command. The insert itself takes writeMu only for the single INSERT
+// — callers must never invoke it while a recall match is in flight
+// (the command side inserts after learn.Recall returns).
+
+// Learn event types mirror the CHECK constraint on the learn_events
+// table created by store.go at schema v9.
+const (
+ LearnEventRecallHit = "recall_hit"
+ LearnEventRecallMiss = "recall_miss"
+ LearnEventRecallPlaybookHit = "recall_playbook_hit"
+ LearnEventTeach = "teach"
+ LearnEventTeachPlaybook = "teach_playbook"
+ LearnEventAmend = "amend"
+ LearnEventForget = "forget"
+ LearnEventCandidateConfirmed = "candidate_confirmed"
+ LearnEventCandidateRejected = "candidate_rejected"
+)
+
+// Learn event surfaces mirror the CHECK constraint on the surface
+// column: which face of the CLI recorded the event.
+const (
+ LearnSurfaceCLI = "cli"
+ LearnSurfaceMCP = "mcp"
+)
+
+// Retention defaults per the measurement contract: the table is capped
+// and pruned opportunistically on stats runs, so a long-lived store
+// never grows an unbounded telemetry tail.
+const (
+ DefaultLearnEventMaxRows = 10000
+ DefaultLearnEventMaxAge = 90 * 24 * time.Hour
+)
+
+// learnEventTimeFormat keeps event timestamps as RFC3339 UTC strings —
+// fixed-width and lexicographically sortable, so ordering comparisons
+// run inside SQL against the TEXT column (with id as the tiebreak for
+// same-second events).
+const learnEventTimeFormat = time.RFC3339
+
+// learnSurfaceEnvVar is the environment variable the MCP server sets
+// (in its own process, inherited by the shelled-out CLI) so events
+// recorded during an MCP-surfaced invocation carry surface=mcp.
+const learnSurfaceEnvVar = "HUMAN_GOAT_LEARN_SURFACE"
+
+// learnSurfaceOverride is the in-process seam for surface detection:
+// an entrypoint that hosts commands without a subprocess boundary can
+// call SetLearnEventSurface instead of relying on the env var. Stored
+// atomically because the MCP server handles tools concurrently.
+var learnSurfaceOverride atomic.Value
+
+// SetLearnEventSurface pins the surface recorded by subsequent
+// LearnEventSurface calls in this process. The MCP entrypoint is the
+// intended caller (a later wiring unit); anything other than
+// LearnSurfaceMCP resets to the default env-var-then-cli resolution.
+func SetLearnEventSurface(surface string) {
+ if surface != LearnSurfaceMCP && surface != LearnSurfaceCLI {
+ surface = ""
+ }
+ learnSurfaceOverride.Store(surface)
+}
+
+// LearnEventSurface resolves which surface is executing: the
+// in-process override wins, then the env var the MCP server sets for
+// its shelled-out CLI children, then the CLI default.
+func LearnEventSurface() string {
+ if v, ok := learnSurfaceOverride.Load().(string); ok && v != "" {
+ return v
+ }
+ if strings.EqualFold(strings.TrimSpace(os.Getenv(learnSurfaceEnvVar)), LearnSurfaceMCP) {
+ return LearnSurfaceMCP
+ }
+ return LearnSurfaceCLI
+}
+
+// validLearnEvent mirrors the table's CHECK constraint so a typo'd
+// event name fails with a readable error instead of a SQL constraint
+// violation.
+func validLearnEvent(event string) bool {
+ switch event {
+ case LearnEventRecallHit, LearnEventRecallMiss, LearnEventRecallPlaybookHit,
+ LearnEventTeach, LearnEventTeachPlaybook, LearnEventAmend,
+ LearnEventForget, LearnEventCandidateConfirmed, LearnEventCandidateRejected:
+ return true
+ }
+ return false
+}
+
+// InsertLearnEvent records one measurement row. matchedRowID <= 0 and
+// an empty familyHash store as NULL. Best-effort by caller contract:
+// callers log the returned error to teach.log and never fail the
+// parent command. The writeMu hold spans exactly this one INSERT.
+func (s *Store) InsertLearnEvent(event, familyHash string, matchedRowID int64, entityMatch bool, surface string) error {
+ // Harness silence: verify/dogfood runs must leave zero measurement
+ // rows in the developer's store, matching the journal's posture.
+ // Env names mirror cliutil.IsVerifyEnv / IsDogfoodEnv (the store
+ // package stays import-light, so the check is inlined here).
+ if os.Getenv("PRINTING_PRESS_VERIFY") == "1" || os.Getenv("PRINTING_PRESS_DOGFOOD") == "1" {
+ return nil
+ }
+ if !validLearnEvent(event) {
+ return fmt.Errorf("insert learn event: unknown event %q", event)
+ }
+ if surface != LearnSurfaceCLI && surface != LearnSurfaceMCP {
+ surface = LearnSurfaceCLI
+ }
+ var rowID any
+ if matchedRowID > 0 {
+ rowID = matchedRowID
+ }
+ var famHash any
+ if strings.TrimSpace(familyHash) != "" {
+ famHash = familyHash
+ }
+ match := 0
+ if entityMatch {
+ match = 1
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ _, err := s.db.Exec(`INSERT INTO learn_events
+ (ts, event, query_family_hash, matched_row_id, entity_match, surface)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ time.Now().UTC().Format(learnEventTimeFormat), event, famHash, rowID, match, surface,
+ )
+ if err != nil {
+ return fmt.Errorf("insert learn event: %w", err)
+ }
+ return nil
+}
+
+// PruneLearnEvents applies the retention policy: rows older than
+// maxAge are dropped, then the oldest rows beyond the maxRows cap.
+// Non-positive arguments take the defaults (10000 rows / 90 days).
+// Returns the number of rows removed. Run opportunistically at stats
+// time; best-effort by caller contract.
+func (s *Store) PruneLearnEvents(maxRows int, maxAge time.Duration) (int64, error) {
+ if maxRows <= 0 {
+ maxRows = DefaultLearnEventMaxRows
+ }
+ if maxAge <= 0 {
+ maxAge = DefaultLearnEventMaxAge
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ var pruned int64
+ cutoff := time.Now().UTC().Add(-maxAge).Format(learnEventTimeFormat)
+ res, err := s.db.Exec(`DELETE FROM learn_events WHERE ts < ?`, cutoff)
+ if err != nil {
+ return 0, fmt.Errorf("prune learn events (age): %w", err)
+ }
+ if n, err := res.RowsAffected(); err == nil {
+ pruned += n
+ }
+
+ // Oldest-first eviction beyond the row cap: keep the newest maxRows
+ // by (ts, id) — id breaks same-second ties so eviction order is
+ // deterministic.
+ res, err = s.db.Exec(`DELETE FROM learn_events WHERE id NOT IN (
+ SELECT id FROM learn_events ORDER BY ts DESC, id DESC LIMIT ?)`, maxRows)
+ if err != nil {
+ return pruned, fmt.Errorf("prune learn events (cap): %w", err)
+ }
+ if n, err := res.RowsAffected(); err == nil {
+ pruned += n
+ }
+ return pruned, nil
+}
+
+// DeleteLearnEventsByFamilyHash is the forget cascade: when a learning
+// is forgotten, its measurement trail goes with it so a forgotten
+// query family leaves no derived record behind. An empty hash deletes
+// nothing (NULL-hash rows are never a family's trail).
+func (s *Store) DeleteLearnEventsByFamilyHash(hash string) (int64, error) {
+ if strings.TrimSpace(hash) == "" {
+ return 0, nil
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ res, err := s.db.Exec(`DELETE FROM learn_events WHERE query_family_hash = ?`, hash)
+ if err != nil {
+ return 0, fmt.Errorf("delete learn events by family hash: %w", err)
+ }
+ return res.RowsAffected()
+}
+
+// LearnEventStats is the aggregation `learnings stats` reports. The
+// three rate fields are nil when their denominator is zero — an empty
+// or thin table reads as "no signal yet", never as a division by zero
+// or a fake 0% — and the counts back each rate so the caller can see
+// the denominator.
+type LearnEventStats struct {
+ RecallHits int64
+ RecallMisses int64
+ // RecallHitRate = hits / (hits + misses).
+ RecallHitRate *float64
+
+ // TaughtRows counts distinct taught learnings: by row id when the
+ // teach event recorded one, by family hash for legacy rows without.
+ TaughtRows int64
+ // ReusedRows counts taught learnings later seen in a recall_hit —
+ // row-id join primary, family-hash fallback — so alias-mediated
+ // recalls credit the taught row even when the query family differs.
+ ReusedRows int64
+ // TeachToReuse = ReusedRows / TaughtRows.
+ TeachToReuse *float64
+
+ PlaybookHits int64
+ // PlaybookHitsAmended counts playbook hits followed by an amend
+ // event on the same family hash — the agent had to correct the
+ // choreography after using it.
+ PlaybookHitsAmended int64
+ // PlaybookResolutionRate = (PlaybookHits - PlaybookHitsAmended) / PlaybookHits.
+ PlaybookResolutionRate *float64
+
+ CandidatesConfirmed int64
+ CandidatesRejected int64
+
+ // EventCounts carries the raw per-event totals.
+ EventCounts map[string]int64
+}
+
+// ratio returns num/den, or nil when den is zero (the zero-division
+// guard every rate in LearnEventStats rides on).
+func ratio(num, den int64) *float64 {
+ if den == 0 {
+ return nil
+ }
+ r := float64(num) / float64(den)
+ return &r
+}
+
+// LearnEventStats aggregates the events table. Read-only: no writeMu,
+// safe to run concurrently with inserts.
+func (s *Store) LearnEventStats(ctx context.Context) (LearnEventStats, error) {
+ stats := LearnEventStats{EventCounts: map[string]int64{}}
+
+ rows, err := s.db.QueryContext(ctx, `SELECT event, COUNT(*) FROM learn_events GROUP BY event`)
+ if err != nil {
+ return stats, fmt.Errorf("learn event stats: counts: %w", err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var event string
+ var n int64
+ if err := rows.Scan(&event, &n); err != nil {
+ return stats, fmt.Errorf("learn event stats: scan: %w", err)
+ }
+ stats.EventCounts[event] = n
+ }
+ if err := rows.Err(); err != nil {
+ return stats, fmt.Errorf("learn event stats: rows: %w", err)
+ }
+
+ stats.RecallHits = stats.EventCounts[LearnEventRecallHit]
+ stats.RecallMisses = stats.EventCounts[LearnEventRecallMiss]
+ stats.RecallHitRate = ratio(stats.RecallHits, stats.RecallHits+stats.RecallMisses)
+ stats.PlaybookHits = stats.EventCounts[LearnEventRecallPlaybookHit]
+ stats.CandidatesConfirmed = stats.EventCounts[LearnEventCandidateConfirmed]
+ stats.CandidatesRejected = stats.EventCounts[LearnEventCandidateRejected]
+
+ // Teach-to-reuse, row-id join primary. A taught row is reused when
+ // a later recall_hit carries the same matched_row_id; "later" is
+ // (ts, id) ordering so same-second sequences still resolve.
+ var taughtByRow, reusedByRow int64
+ if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT matched_row_id)
+ FROM learn_events WHERE event = ? AND matched_row_id IS NOT NULL`,
+ LearnEventTeach).Scan(&taughtByRow); err != nil {
+ return stats, fmt.Errorf("learn event stats: taught by row: %w", err)
+ }
+ if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT t.matched_row_id)
+ FROM learn_events t
+ WHERE t.event = ? AND t.matched_row_id IS NOT NULL
+ AND EXISTS (SELECT 1 FROM learn_events h
+ WHERE h.event = ? AND h.matched_row_id = t.matched_row_id
+ AND (h.ts > t.ts OR (h.ts = t.ts AND h.id > t.id)))`,
+ LearnEventTeach, LearnEventRecallHit).Scan(&reusedByRow); err != nil {
+ return stats, fmt.Errorf("learn event stats: reused by row: %w", err)
+ }
+
+ // Family-hash fallback for legacy teach rows that predate row-id
+ // recording. Row-id-carrying rows are excluded so nothing counts
+ // twice.
+ var taughtByFam, reusedByFam int64
+ if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT query_family_hash)
+ FROM learn_events WHERE event = ? AND matched_row_id IS NULL
+ AND query_family_hash IS NOT NULL`,
+ LearnEventTeach).Scan(&taughtByFam); err != nil {
+ return stats, fmt.Errorf("learn event stats: taught by family: %w", err)
+ }
+ if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT t.query_family_hash)
+ FROM learn_events t
+ WHERE t.event = ? AND t.matched_row_id IS NULL AND t.query_family_hash IS NOT NULL
+ AND EXISTS (SELECT 1 FROM learn_events h
+ WHERE h.event = ? AND h.query_family_hash = t.query_family_hash
+ AND (h.ts > t.ts OR (h.ts = t.ts AND h.id > t.id)))`,
+ LearnEventTeach, LearnEventRecallHit).Scan(&reusedByFam); err != nil {
+ return stats, fmt.Errorf("learn event stats: reused by family: %w", err)
+ }
+ stats.TaughtRows = taughtByRow + taughtByFam
+ stats.ReusedRows = reusedByRow + reusedByFam
+ stats.TeachToReuse = ratio(stats.ReusedRows, stats.TaughtRows)
+
+ // Playbook resolution: a playbook hit succeeded unless a later
+ // amend event landed on the same family hash.
+ if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*)
+ FROM learn_events p
+ WHERE p.event = ? AND p.query_family_hash IS NOT NULL
+ AND EXISTS (SELECT 1 FROM learn_events a
+ WHERE a.event = ? AND a.query_family_hash = p.query_family_hash
+ AND (a.ts > p.ts OR (a.ts = p.ts AND a.id > p.id)))`,
+ LearnEventRecallPlaybookHit, LearnEventAmend).Scan(&stats.PlaybookHitsAmended); err != nil {
+ return stats, fmt.Errorf("learn event stats: amended playbook hits: %w", err)
+ }
+ stats.PlaybookResolutionRate = ratio(stats.PlaybookHits-stats.PlaybookHitsAmended, stats.PlaybookHits)
+
+ return stats, nil
+}
diff --git a/library/productivity/human-goat/internal/store/events_test.go b/library/productivity/human-goat/internal/store/events_test.go
new file mode 100644
index 0000000000..6623fc54a4
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/events_test.go
@@ -0,0 +1,477 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+func openTestEventsStore(t *testing.T) *Store {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "events.db")
+ s, err := OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func countLearnEvents(t *testing.T, s *Store, event string) int64 {
+ t.Helper()
+ var n int64
+ if err := s.DB().QueryRow(
+ `SELECT COUNT(*) FROM learn_events WHERE event = ?`, event,
+ ).Scan(&n); err != nil {
+ t.Fatalf("count learn events: %v", err)
+ }
+ return n
+}
+
+// backdateLearnEvent pushes one event's ts into the past so retention
+// and ordering paths can be exercised without waiting.
+func backdateLearnEvent(t *testing.T, s *Store, id int64, age time.Duration) {
+ t.Helper()
+ old := time.Now().UTC().Add(-age).Format(learnEventTimeFormat)
+ if _, err := s.DB().Exec(
+ `UPDATE learn_events SET ts = ? WHERE id = ?`, old, id,
+ ); err != nil {
+ t.Fatalf("backdate event %d: %v", id, err)
+ }
+}
+
+func TestLearnEvent_InsertStoresRow(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ if err := s.InsertLearnEvent(LearnEventRecallHit, "fam-hash-1", 42, true, LearnSurfaceCLI); err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ var (
+ event, famHash, surface string
+ rowID int64
+ entityMatch int
+ )
+ if err := s.DB().QueryRow(`SELECT event, query_family_hash, matched_row_id, entity_match, surface
+ FROM learn_events`).Scan(&event, &famHash, &rowID, &entityMatch, &surface); err != nil {
+ t.Fatalf("read back: %v", err)
+ }
+ if event != LearnEventRecallHit || famHash != "fam-hash-1" || rowID != 42 || entityMatch != 1 || surface != LearnSurfaceCLI {
+ t.Errorf("stored row = (%s, %s, %d, %d, %s), want (recall_hit, fam-hash-1, 42, 1, cli)",
+ event, famHash, rowID, entityMatch, surface)
+ }
+}
+
+func TestLearnEvent_InsertNullsForZeroValues(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ if err := s.InsertLearnEvent(LearnEventRecallMiss, "", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+ var nullHash, nullRow int64
+ if err := s.DB().QueryRow(`SELECT
+ SUM(CASE WHEN query_family_hash IS NULL THEN 1 ELSE 0 END),
+ SUM(CASE WHEN matched_row_id IS NULL THEN 1 ELSE 0 END)
+ FROM learn_events`).Scan(&nullHash, &nullRow); err != nil {
+ t.Fatalf("read back: %v", err)
+ }
+ if nullHash != 1 || nullRow != 1 {
+ t.Errorf("empty familyHash / zero rowID should store NULL; got nullHash=%d nullRow=%d", nullHash, nullRow)
+ }
+}
+
+func TestLearnEvent_InsertRejectsUnknownEvent(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ if err := s.InsertLearnEvent("not_an_event", "", 0, false, LearnSurfaceCLI); err == nil {
+ t.Fatal("unknown event should error before hitting the SQL CHECK")
+ }
+ if got := countLearnEvents(t, s, "not_an_event"); got != 0 {
+ t.Errorf("no row should land for an unknown event; got %d", got)
+ }
+}
+
+func TestLearnEvent_InsertOnClosedStoreErrorsWithoutPanic(t *testing.T) {
+ // The best-effort contract: a broken store yields an error the
+ // caller logs, never a panic.
+ dbPath := filepath.Join(t.TempDir(), "closed.db")
+ s, err := OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ s.Close()
+ if err := s.InsertLearnEvent(LearnEventRecallHit, "fam", 1, true, LearnSurfaceCLI); err == nil {
+ t.Fatal("insert on a closed store should return an error")
+ }
+}
+
+func TestLearnEvent_SurfaceDetection(t *testing.T) {
+ // Not parallel: mutates process env and the in-process override.
+ t.Cleanup(func() { SetLearnEventSurface("") })
+
+ SetLearnEventSurface("")
+ t.Setenv(learnSurfaceEnvVar, "")
+ if got := LearnEventSurface(); got != LearnSurfaceCLI {
+ t.Errorf("default surface = %q, want cli", got)
+ }
+
+ t.Setenv(learnSurfaceEnvVar, "mcp")
+ if got := LearnEventSurface(); got != LearnSurfaceMCP {
+ t.Errorf("env-var surface = %q, want mcp", got)
+ }
+
+ t.Setenv(learnSurfaceEnvVar, "")
+ SetLearnEventSurface(LearnSurfaceMCP)
+ if got := LearnEventSurface(); got != LearnSurfaceMCP {
+ t.Errorf("override surface = %q, want mcp", got)
+ }
+
+ // An invalid override resets to default resolution.
+ SetLearnEventSurface("bogus")
+ if got := LearnEventSurface(); got != LearnSurfaceCLI {
+ t.Errorf("reset surface = %q, want cli", got)
+ }
+}
+
+func TestLearnEvent_ConcurrentInsertsLoseNothing(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ const writers = 8
+ const perWriter = 20
+ var wg sync.WaitGroup
+ errs := make(chan error, writers*perWriter)
+ for w := 0; w < writers; w++ {
+ wg.Add(1)
+ go func(w int) {
+ defer wg.Done()
+ for i := 0; i < perWriter; i++ {
+ if err := s.InsertLearnEvent(LearnEventRecallHit,
+ fmt.Sprintf("fam-%d", w), int64(w*perWriter+i+1), true, LearnSurfaceCLI); err != nil {
+ errs <- err
+ }
+ }
+ }(w)
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ t.Errorf("concurrent insert: %v", err)
+ }
+ if got := countLearnEvents(t, s, LearnEventRecallHit); got != writers*perWriter {
+ t.Errorf("want %d rows, got %d", writers*perWriter, got)
+ }
+}
+
+func TestPruneLearnEvents_RowCapDropsOldest(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ for i := 0; i < 10; i++ {
+ if err := s.InsertLearnEvent(LearnEventRecallMiss, fmt.Sprintf("fam-%d", i), 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed %d: %v", i, err)
+ }
+ }
+ // Same-second inserts: eviction order falls to the id tiebreak, so
+ // ids 1..6 are the oldest.
+ pruned, err := s.PruneLearnEvents(4, DefaultLearnEventMaxAge)
+ if err != nil {
+ t.Fatalf("prune: %v", err)
+ }
+ if pruned != 6 {
+ t.Errorf("pruned = %d, want 6", pruned)
+ }
+ var minID int64
+ if err := s.DB().QueryRow(`SELECT MIN(id) FROM learn_events`).Scan(&minID); err != nil {
+ t.Fatalf("min id: %v", err)
+ }
+ if minID != 7 {
+ t.Errorf("oldest surviving id = %d, want 7 (newest-4 retained)", minID)
+ }
+}
+
+func TestPruneLearnEvents_AgeCutoff(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ for i := 0; i < 3; i++ {
+ if err := s.InsertLearnEvent(LearnEventRecallMiss, "", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed %d: %v", i, err)
+ }
+ }
+ backdateLearnEvent(t, s, 1, 91*24*time.Hour)
+
+ pruned, err := s.PruneLearnEvents(0, 0) // defaults: 10000 rows / 90 days
+ if err != nil {
+ t.Fatalf("prune: %v", err)
+ }
+ if pruned != 1 {
+ t.Errorf("pruned = %d, want 1 (only the backdated row)", pruned)
+ }
+ if got := countLearnEvents(t, s, LearnEventRecallMiss); got != 2 {
+ t.Errorf("surviving rows = %d, want 2", got)
+ }
+}
+
+func TestDeleteLearnEventsByFamilyHash_CascadesOneFamilyOnly(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ if err := s.InsertLearnEvent(LearnEventTeach, "fam-a", 1, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if err := s.InsertLearnEvent(LearnEventRecallHit, "fam-a", 1, true, LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if err := s.InsertLearnEvent(LearnEventTeach, "fam-b", 2, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ // A NULL-hash row must survive even a hostile empty-hash call.
+ if err := s.InsertLearnEvent(LearnEventRecallMiss, "", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ n, err := s.DeleteLearnEventsByFamilyHash("fam-a")
+ if err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if n != 2 {
+ t.Errorf("deleted = %d, want 2", n)
+ }
+
+ n, err = s.DeleteLearnEventsByFamilyHash("")
+ if err != nil {
+ t.Fatalf("empty-hash delete: %v", err)
+ }
+ if n != 0 {
+ t.Errorf("empty hash must delete nothing; deleted %d", n)
+ }
+
+ var remaining int64
+ if err := s.DB().QueryRow(`SELECT COUNT(*) FROM learn_events`).Scan(&remaining); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if remaining != 2 {
+ t.Errorf("remaining = %d, want 2 (fam-b teach + NULL-hash miss)", remaining)
+ }
+}
+
+func TestLearnEventStats_EmptyTableGuardsZeroDivision(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ stats, err := s.LearnEventStats(context.Background())
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if stats.RecallHitRate != nil {
+ t.Errorf("empty table: RecallHitRate = %v, want nil", *stats.RecallHitRate)
+ }
+ if stats.TeachToReuse != nil {
+ t.Errorf("empty table: TeachToReuse = %v, want nil", *stats.TeachToReuse)
+ }
+ if stats.PlaybookResolutionRate != nil {
+ t.Errorf("empty table: PlaybookResolutionRate = %v, want nil", *stats.PlaybookResolutionRate)
+ }
+ if stats.CandidatesConfirmed != 0 || stats.CandidatesRejected != 0 {
+ t.Errorf("empty table: candidate counts = (%d, %d), want zeros",
+ stats.CandidatesConfirmed, stats.CandidatesRejected)
+ }
+ if len(stats.EventCounts) != 0 {
+ t.Errorf("empty table: EventCounts = %v, want empty", stats.EventCounts)
+ }
+}
+
+func TestLearnEventStats_MissTeachHitSequence(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ // The canonical loop: cold miss, teach, warm hit on the same row.
+ if err := s.InsertLearnEvent(LearnEventRecallMiss, "fam-x", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("miss: %v", err)
+ }
+ if err := s.InsertLearnEvent(LearnEventTeach, "fam-x", 7, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+ if err := s.InsertLearnEvent(LearnEventRecallHit, "fam-x", 7, true, LearnSurfaceCLI); err != nil {
+ t.Fatalf("hit: %v", err)
+ }
+
+ stats, err := s.LearnEventStats(context.Background())
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if stats.RecallHits != 1 || stats.RecallMisses != 1 {
+ t.Errorf("hits/misses = %d/%d, want 1/1", stats.RecallHits, stats.RecallMisses)
+ }
+ if stats.RecallHitRate == nil || *stats.RecallHitRate != 0.5 {
+ t.Errorf("RecallHitRate = %v, want 0.5", stats.RecallHitRate)
+ }
+ if stats.TaughtRows != 1 || stats.ReusedRows != 1 {
+ t.Errorf("taught/reused = %d/%d, want 1/1", stats.TaughtRows, stats.ReusedRows)
+ }
+ if stats.TeachToReuse == nil || *stats.TeachToReuse != 1.0 {
+ t.Errorf("TeachToReuse = %v, want 1.0", stats.TeachToReuse)
+ }
+}
+
+func TestLearnEventStats_RowIDJoinCreditsCrossFamilyReuse(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ // Alias-mediated reuse: the recall hit lands under a DIFFERENT
+ // family hash than the teach (paraphrase or cross-alias recall),
+ // but the same learning row id. A family-hash-only join would miss
+ // this credit — the row-id join is the primary path by design.
+ if err := s.InsertLearnEvent(LearnEventTeach, "fam-taught", 9, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+ if err := s.InsertLearnEvent(LearnEventRecallHit, "fam-other", 9, true, LearnSurfaceCLI); err != nil {
+ t.Fatalf("hit: %v", err)
+ }
+
+ stats, err := s.LearnEventStats(context.Background())
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if stats.ReusedRows != 1 {
+ t.Errorf("ReusedRows = %d, want 1 (row-id join must credit cross-family reuse)", stats.ReusedRows)
+ }
+ if stats.TeachToReuse == nil || *stats.TeachToReuse != 1.0 {
+ t.Errorf("TeachToReuse = %v, want 1.0", stats.TeachToReuse)
+ }
+}
+
+func TestLearnEventStats_FamilyHashFallbackForLegacyTeachRows(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ // Legacy teach row without a row id: only the family-hash fallback
+ // can credit it.
+ if err := s.InsertLearnEvent(LearnEventTeach, "fam-legacy", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+ if err := s.InsertLearnEvent(LearnEventRecallHit, "fam-legacy", 33, true, LearnSurfaceCLI); err != nil {
+ t.Fatalf("hit: %v", err)
+ }
+
+ stats, err := s.LearnEventStats(context.Background())
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if stats.TaughtRows != 1 || stats.ReusedRows != 1 {
+ t.Errorf("taught/reused = %d/%d, want 1/1 via family-hash fallback",
+ stats.TaughtRows, stats.ReusedRows)
+ }
+}
+
+func TestLearnEventStats_TeachWithoutReuseCountsZero(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ if err := s.InsertLearnEvent(LearnEventTeach, "fam-cold", 5, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("teach: %v", err)
+ }
+
+ stats, err := s.LearnEventStats(context.Background())
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if stats.TaughtRows != 1 || stats.ReusedRows != 0 {
+ t.Errorf("taught/reused = %d/%d, want 1/0", stats.TaughtRows, stats.ReusedRows)
+ }
+ if stats.TeachToReuse == nil || *stats.TeachToReuse != 0 {
+ t.Errorf("TeachToReuse = %v, want 0", stats.TeachToReuse)
+ }
+}
+
+func TestLearnEventStats_PlaybookResolutionSubtractsAmendedHits(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ // Two playbook hits; the second is followed by an amend on the same
+ // family — the choreography needed correction after use.
+ if err := s.InsertLearnEvent(LearnEventRecallPlaybookHit, "fam-good", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("hit good: %v", err)
+ }
+ if err := s.InsertLearnEvent(LearnEventRecallPlaybookHit, "fam-bad", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("hit bad: %v", err)
+ }
+ if err := s.InsertLearnEvent(LearnEventAmend, "fam-bad", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("amend: %v", err)
+ }
+
+ stats, err := s.LearnEventStats(context.Background())
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if stats.PlaybookHits != 2 || stats.PlaybookHitsAmended != 1 {
+ t.Errorf("playbook hits/amended = %d/%d, want 2/1", stats.PlaybookHits, stats.PlaybookHitsAmended)
+ }
+ if stats.PlaybookResolutionRate == nil || *stats.PlaybookResolutionRate != 0.5 {
+ t.Errorf("PlaybookResolutionRate = %v, want 0.5", stats.PlaybookResolutionRate)
+ }
+}
+
+func TestLearnEventStats_CandidateJudgmentCounts(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ for i := 0; i < 2; i++ {
+ if err := s.InsertLearnEvent(LearnEventCandidateConfirmed, "fam-c", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("confirm %d: %v", i, err)
+ }
+ }
+ if err := s.InsertLearnEvent(LearnEventCandidateRejected, "fam-r", 0, false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("reject: %v", err)
+ }
+
+ stats, err := s.LearnEventStats(context.Background())
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if stats.CandidatesConfirmed != 2 || stats.CandidatesRejected != 1 {
+ t.Errorf("confirmed/rejected = %d/%d, want 2/1",
+ stats.CandidatesConfirmed, stats.CandidatesRejected)
+ }
+}
+
+func TestLearnEventStats_ComputesAfterPrune(t *testing.T) {
+ t.Parallel()
+ s := openTestEventsStore(t)
+
+ for i := 0; i < 12; i++ {
+ event := LearnEventRecallMiss
+ if i%2 == 0 {
+ event = LearnEventRecallHit
+ }
+ if err := s.InsertLearnEvent(event, "fam-p", int64(i+1), false, LearnSurfaceCLI); err != nil {
+ t.Fatalf("seed %d: %v", i, err)
+ }
+ }
+ if _, err := s.PruneLearnEvents(6, DefaultLearnEventMaxAge); err != nil {
+ t.Fatalf("prune: %v", err)
+ }
+
+ stats, err := s.LearnEventStats(context.Background())
+ if err != nil {
+ t.Fatalf("stats after prune: %v", err)
+ }
+ if got := stats.RecallHits + stats.RecallMisses; got != 6 {
+ t.Errorf("events after prune = %d, want 6", got)
+ }
+ if stats.RecallHitRate == nil {
+ t.Error("RecallHitRate should still compute after prune")
+ }
+}
diff --git a/library/productivity/human-goat/internal/store/extras.go b/library/productivity/human-goat/internal/store/extras.go
new file mode 100644
index 0000000000..52545e367d
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/extras.go
@@ -0,0 +1,28 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+
+package store
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+)
+
+// migrateExtras runs after the generated store migrations and before the
+// schema-version stamp. It is the canonical place for novel-feature auxiliary
+// tables that need to live in the local store.
+//
+// Edit this file when adding tables for novel commands. Keep migrations
+// idempotent with CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS so
+// every store open can safely re-run them.
+func (s *Store) migrateExtras(ctx context.Context, conn *sql.Conn) error {
+ migrations := []string{
+ // Add CREATE TABLE IF NOT EXISTS statements here.
+ }
+ for _, m := range migrations {
+ if _, err := conn.ExecContext(ctx, m); err != nil {
+ return fmt.Errorf("extra migration failed: %w", err)
+ }
+ }
+ return nil
+}
diff --git a/library/productivity/human-goat/internal/store/learnings.go b/library/productivity/human-goat/internal/store/learnings.go
new file mode 100644
index 0000000000..a06677451d
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/learnings.go
@@ -0,0 +1,844 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+)
+
+// Learning action constants. See LearningRow.Action.
+const (
+ LearningActionBoost = "boost"
+ LearningActionHide = "hide"
+ LearningActionAlias = "alias_of"
+ LearningSourceTaught = "taught"
+ LearningSourceManual = "manual-forget"
+ defaultJaccardMin = 0.6
+ defaultRecallLimit = 10
+)
+
+// LearningRow is one row in the search_learnings table — the LLM-driven
+// per-query reranking signal. Populated by the `teach` command at
+// response-time and read by the rerank Apply pass in topic/compare on
+// subsequent queries.
+//
+// QueryPattern is always stored normalized (lowercase, whitespace-
+// collapsed, stopwords stripped) so two semantically-equivalent natural-
+// language queries (e.g., "What are Portugal's World Cup odds?" and
+// "portugal world cup odds") collapse to the same row.
+type LearningRow struct {
+ ID int64 `json:"id"`
+ QueryPattern string `json:"query_pattern"`
+ Venue string `json:"venue,omitempty"`
+ ResourceType string `json:"resource_type,omitempty"`
+ ResourceID string `json:"resource_id"`
+ Action string `json:"action"`
+ AliasTarget string `json:"alias_target,omitempty"`
+ Source string `json:"source"`
+ Confidence int `json:"confidence"`
+ CreatedAt time.Time `json:"created_at"`
+ LastObservedAt *time.Time `json:"last_observed_at,omitempty"`
+ Notes string `json:"notes,omitempty"`
+}
+
+// stopwords stripped from query patterns to normalize semantically
+// equivalent natural-language queries. Conservative set — only the
+// tokens that bracket every variant of "what are Portugal's odds".
+var queryStopwords = map[string]struct{}{
+ "a": {}, "an": {}, "the": {},
+ "what": {}, "whats": {},
+ "are": {}, "is": {}, "was": {}, "were": {},
+ "do": {}, "does": {}, "did": {},
+ "of": {}, "for": {}, "to": {}, "on": {},
+ "in": {}, "at": {}, "by": {}, "with": {},
+ "odds": {}, // odds-flavored helper word that exists on both sides
+}
+
+// defaultQuerySynonyms are the domain-neutral same-referent phrasing
+// folds (variant -> canonical) applied inside NormalizeQuery. Each
+// pair MUST name the same referent — never fold across day boundaries
+// ("tonight" is not "yesterday"). This map mirrors defaultSynonyms in
+// internal/learn/entities (this package must stay import-free of the
+// learn tree); a generator test pins the two copies identical.
+var defaultQuerySynonyms = map[string]string{
+ "last night": "yesterday",
+ "tonite": "tonight",
+ "to-day": "today",
+ "to-night": "tonight",
+ "to-morrow": "tomorrow",
+ "tmrw": "tomorrow",
+}
+
+// querySynonymRule is one compiled fold: variant and canonical are
+// pre-tokenized through the same character filter normalizeAndTokens
+// uses, so a hyphenated variant like "to-day" matches its
+// post-tokenization shape ("to" + "day").
+type querySynonymRule struct {
+ variant []string
+ canonical []string
+}
+
+var (
+ // querySynonyms accumulates defaults plus RegisterQuerySynonyms
+ // additions; querySynonymRules is its compiled form. Package-level
+ // by necessity: NormalizeQuery is a package function with no config
+ // receiver. Registration is one-shot at CLI startup (before any
+ // store use), matching the entities.Config mutation contract.
+ querySynonyms = copyQuerySynonymDefaults()
+ querySynonymRules = compileQuerySynonyms(querySynonyms)
+)
+
+func copyQuerySynonymDefaults() map[string]string {
+ m := make(map[string]string, len(defaultQuerySynonyms)+8)
+ for v, c := range defaultQuerySynonyms {
+ m[v] = c
+ }
+ return m
+}
+
+// RegisterQuerySynonyms merges per-CLI same-referent phrasing folds
+// (variant -> canonical) into the write-side normalizer. Called once
+// at CLI startup by the generated learn-init shim with the spec's
+// declared synonyms — the same map it registers on the read-side
+// entities.Config, keeping the two normalizers symmetric. Entries
+// with an empty side are dropped; folding is a single hop.
+func RegisterQuerySynonyms(synonyms map[string]string) {
+ changed := false
+ for v, canonical := range synonyms {
+ v = strings.ToLower(strings.TrimSpace(v))
+ canonical = strings.ToLower(strings.TrimSpace(canonical))
+ if v == "" || canonical == "" || v == canonical {
+ continue
+ }
+ querySynonyms[v] = canonical
+ changed = true
+ }
+ if changed {
+ querySynonymRules = compileQuerySynonyms(querySynonyms)
+ }
+}
+
+// compileQuerySynonyms tokenizes each pair through the normalization
+// character filter and orders rules longest-variant-first (ties
+// lexicographic) so multiword folds win deterministically.
+func compileQuerySynonyms(synonyms map[string]string) []querySynonymRule {
+ rules := make([]querySynonymRule, 0, len(synonyms))
+ for v, canonical := range synonyms {
+ variantTokens := queryCharTokens(v)
+ canonicalTokens := queryCharTokens(canonical)
+ if len(variantTokens) == 0 || len(canonicalTokens) == 0 {
+ continue
+ }
+ rules = append(rules, querySynonymRule{variant: variantTokens, canonical: canonicalTokens})
+ }
+ sort.Slice(rules, func(i, j int) bool {
+ if len(rules[i].variant) != len(rules[j].variant) {
+ return len(rules[i].variant) > len(rules[j].variant)
+ }
+ return strings.Join(rules[i].variant, " ") < strings.Join(rules[j].variant, " ")
+ })
+ return rules
+}
+
+// queryCharTokens lowercases s, replaces every non-alphanumeric rune
+// with a space, and splits into tokens — the shared first stage of
+// NormalizeQuery and of synonym-rule compilation, so variants match
+// their post-tokenization shape.
+func queryCharTokens(s string) []string {
+ s = strings.ToLower(strings.TrimSpace(s))
+ b := strings.Builder{}
+ b.Grow(len(s))
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z':
+ b.WriteRune(r)
+ case r >= '0' && r <= '9':
+ b.WriteRune(r)
+ default:
+ b.WriteByte(' ')
+ }
+ }
+ return strings.Fields(b.String())
+}
+
+// foldQueryTokens rewrites registered variant token sequences to their
+// canonical forms. Greedy left-to-right, longest rule first. Runs
+// BEFORE stopword filtering so a variant containing a stopword-shaped
+// token ("to" in "to-day" -> "to day") still folds as a unit.
+func foldQueryTokens(tokens []string) []string {
+ rules := querySynonymRules
+ if len(rules) == 0 || len(tokens) == 0 {
+ return tokens
+ }
+ out := make([]string, 0, len(tokens))
+ for i := 0; i < len(tokens); {
+ matched := false
+ for r := range rules {
+ rule := &rules[r]
+ if i+len(rule.variant) > len(tokens) {
+ continue
+ }
+ ok := true
+ for k, vt := range rule.variant {
+ if tokens[i+k] != vt {
+ ok = false
+ break
+ }
+ }
+ if !ok {
+ continue
+ }
+ out = append(out, rule.canonical...)
+ i += len(rule.variant)
+ matched = true
+ break
+ }
+ if !matched {
+ out = append(out, tokens[i])
+ i++
+ }
+ }
+ return out
+}
+
+// NormalizeQuery lowercases, strips punctuation, collapses whitespace,
+// folds same-referent synonym phrasings to their canonical form, and
+// removes a small stopword set. Exported so the CLI layer uses
+// the same normalization at both write (teach) and read (recall + apply)
+// time. The token set used by the Jaccard match is a side product —
+// see normalizeAndTokens.
+func NormalizeQuery(s string) string {
+ normalized, _ := normalizeAndTokens(s)
+ return normalized
+}
+
+// normalizeAndTokens returns both the normalized string and the set of
+// non-stopword tokens. The token set is the canonical form the recall
+// Jaccard matcher uses; the string form is the canonical key under
+// which a learning is stored.
+//
+// Stage order matters: character filtering, then synonym folding, then
+// stopword/dedupe filtering. Folding before stopword removal keeps
+// multiword variants intact ("to day" must fold before "to" drops),
+// and folding at the write path here plus the read path's
+// entities.Config fold is what keeps teach and recall keyed alike.
+func normalizeAndTokens(s string) (string, map[string]struct{}) {
+ rawTokens := foldQueryTokens(queryCharTokens(s))
+ tokens := make(map[string]struct{}, len(rawTokens))
+ kept := make([]string, 0, len(rawTokens))
+ for _, t := range rawTokens {
+ if t == "" || t == "s" {
+ continue
+ }
+ if _, drop := queryStopwords[t]; drop {
+ continue
+ }
+ if _, dup := tokens[t]; dup {
+ continue
+ }
+ tokens[t] = struct{}{}
+ kept = append(kept, t)
+ }
+ return strings.Join(kept, " "), tokens
+}
+
+// UpsertLearningInput is the call-shape Upsert accepts. ResourceType and
+// Venue may be empty; Action defaults to "boost" when empty.
+//
+// QueryEntities is the optional precomputed entity slice the caller
+// extracted from the raw (pre-normalization) Query. When non-nil it
+// is JSON-marshaled and written to the search_learnings.query_entities
+// column on insert. When nil, the column is left NULL; a future
+// re-Open pass or manual backfill can populate it from the raw query.
+//
+// Why caller-provided rather than auto-extracted here: the store
+// package is domain-agnostic by design (see learnings.go's package
+// comment). Domain-specific ticker patterns and stopword vocabulary
+// live in internal/learn (one layer up), and the entities themselves
+// come from the case-preserving raw query — which is lost by the
+// time NormalizeQuery has run inside this function. The CLI teach
+// path computes entities from the raw query before calling Upsert
+// and passes them through here.
+type UpsertLearningInput struct {
+ Query string
+ QueryEntities []string
+ ResourceID string
+ ResourceType string
+ Venue string
+ Action string
+ AliasTarget string
+ Source string
+ Notes string
+}
+
+// UpsertLearning inserts a learning row or, when (query_pattern,
+// resource_id, action) already exists, bumps confidence by 1 and
+// refreshes last_observed_at. Source on the existing row is preserved;
+// only confidence + last_observed_at update on re-teach. Returns the
+// row's ID and a bool indicating whether the row was newly inserted.
+func (s *Store) UpsertLearning(ctx context.Context, in UpsertLearningInput) (int64, bool, error) {
+ if strings.TrimSpace(in.ResourceID) == "" {
+ return 0, false, fmt.Errorf("upsert learning: resource_id is required")
+ }
+ action := in.Action
+ if action == "" {
+ action = LearningActionBoost
+ }
+ switch action {
+ case LearningActionBoost, LearningActionHide, LearningActionAlias:
+ default:
+ return 0, false, fmt.Errorf("upsert learning: invalid action %q", action)
+ }
+ if action == LearningActionAlias && strings.TrimSpace(in.AliasTarget) == "" {
+ return 0, false, fmt.Errorf("upsert learning: action=alias_of requires alias_target")
+ }
+ source := in.Source
+ if source == "" {
+ source = LearningSourceTaught
+ }
+ pattern := NormalizeQuery(in.Query)
+ if pattern == "" {
+ return 0, false, fmt.Errorf("upsert learning: query normalized to empty string")
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ now := time.Now().UTC()
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, false, err
+ }
+ defer tx.Rollback()
+
+ var existingID int64
+ err = tx.QueryRowContext(ctx,
+ `SELECT id FROM search_learnings
+ WHERE query_pattern = ? AND resource_id = ? AND action = ?`,
+ pattern, in.ResourceID, action,
+ ).Scan(&existingID)
+ if err == nil {
+ if _, err := tx.ExecContext(ctx,
+ `UPDATE search_learnings
+ SET confidence = confidence + 1, last_observed_at = ?
+ WHERE id = ?`,
+ now, existingID,
+ ); err != nil {
+ return 0, false, fmt.Errorf("upsert learning bump confidence: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, false, err
+ }
+ return existingID, false, nil
+ }
+ if err != sql.ErrNoRows {
+ return 0, false, fmt.Errorf("upsert learning lookup: %w", err)
+ }
+
+ var venue, resourceType, aliasTarget, notes, queryEntities any
+ if in.Venue != "" {
+ venue = in.Venue
+ }
+ if in.ResourceType != "" {
+ resourceType = in.ResourceType
+ }
+ if in.AliasTarget != "" {
+ aliasTarget = in.AliasTarget
+ }
+ if in.Notes != "" {
+ notes = in.Notes
+ }
+ // Materialize the entity slice as a JSON array string so the
+ // column stores a stable shape ("[]" or "[\"Portugal\"]") rather
+ // than NULL on rows that the caller did provide entities for.
+ // Nil slice means "caller has nothing to say"; the column stays
+ // NULL and a later migration / re-Open / U3 read path runs the
+ // extractor against query_pattern as a fallback.
+ if in.QueryEntities != nil {
+ // Marshal an empty slice as the literal "[]" so the column
+ // distinguishes "caller said no entities" from "caller didn't
+ // provide entities at all." json.Marshal turns nil into
+ // "null"; we handle the nil branch above to leave NULL.
+ ents := in.QueryEntities
+ b, err := json.Marshal(ents)
+ if err != nil {
+ return 0, false, fmt.Errorf("marshal query entities: %w", err)
+ }
+ queryEntities = string(b)
+ }
+
+ // U4: initial confidence is 2, not 1. The skill protocol says "skip
+ // discovery on confidence>=2 + entity_match=exact", so landing at 1
+ // meant the very first re-use never tripped the fast path -- the
+ // learning effectively wasn't useful until its second observation,
+ // which usually came too late to matter. Starting at 2 means the
+ // first taught mapping is immediately load-bearing. Re-confirmations
+ // (the UPDATE branch above) still bump by 1, so high-confidence rows
+ // (3+) remain a meaningful signal for ranking ties.
+ res, err := tx.Exec(
+ `INSERT INTO search_learnings
+ (query_pattern, query_entities, venue, resource_type, resource_id, action, alias_target,
+ source, confidence, created_at, last_observed_at, notes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 2, ?, ?, ?)`,
+ pattern, queryEntities, venue, resourceType, in.ResourceID, action, aliasTarget,
+ source, now, now, notes,
+ )
+ if err != nil {
+ return 0, false, fmt.Errorf("upsert learning insert: %w", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return 0, false, fmt.Errorf("upsert learning last id: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, false, err
+ }
+ return id, true, nil
+}
+
+// ListLearningsFilter narrows ListLearnings. Zero values mean unfiltered.
+type ListLearningsFilter struct {
+ Query string
+ Source string
+ ResourceID string
+ Action string
+ Limit int
+ MinConfidence int
+}
+
+// ListLearnings returns rows ordered by last_observed_at DESC. The query
+// filter applies a normalized LIKE match against query_pattern so a
+// filter value of "portugal" matches a row taught for "portugal world
+// cup odds".
+func (s *Store) ListLearnings(ctx context.Context, f ListLearningsFilter) ([]LearningRow, error) {
+ clauses := []string{}
+ args := []any{}
+ if f.Query != "" {
+ clauses = append(clauses, "query_pattern LIKE ?")
+ args = append(args, "%"+NormalizeQuery(f.Query)+"%")
+ }
+ if f.Source != "" {
+ clauses = append(clauses, "source = ?")
+ args = append(args, f.Source)
+ }
+ if f.ResourceID != "" {
+ clauses = append(clauses, "resource_id = ?")
+ args = append(args, f.ResourceID)
+ }
+ if f.Action != "" {
+ clauses = append(clauses, "action = ?")
+ args = append(args, f.Action)
+ }
+ if f.MinConfidence > 0 {
+ clauses = append(clauses, "confidence >= ?")
+ args = append(args, f.MinConfidence)
+ }
+ where := ""
+ if len(clauses) > 0 {
+ where = "WHERE " + strings.Join(clauses, " AND ")
+ }
+ limit := f.Limit
+ if limit <= 0 {
+ limit = 200
+ }
+ args = append(args, limit)
+
+ q := fmt.Sprintf(`SELECT id, query_pattern, COALESCE(venue, ''), COALESCE(resource_type, ''),
+ resource_id, action, COALESCE(alias_target, ''), source, confidence,
+ created_at, last_observed_at, COALESCE(notes, '')
+ FROM search_learnings %s
+ ORDER BY last_observed_at DESC, id DESC
+ LIMIT ?`, where)
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, fmt.Errorf("list learnings: %w", err)
+ }
+ defer rows.Close()
+
+ out := make([]LearningRow, 0)
+ for rows.Next() {
+ var r LearningRow
+ var lastObs sql.NullTime
+ if err := rows.Scan(&r.ID, &r.QueryPattern, &r.Venue, &r.ResourceType,
+ &r.ResourceID, &r.Action, &r.AliasTarget, &r.Source, &r.Confidence,
+ &r.CreatedAt, &lastObs, &r.Notes); err != nil {
+ return nil, err
+ }
+ if lastObs.Valid {
+ t := lastObs.Time
+ r.LastObservedAt = &t
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// ForgetLearningsFilter selects which rows to delete. At least one
+// non-empty field is required; pass All=true to wipe a query pattern
+// without specifying resource or action.
+type ForgetLearningsFilter struct {
+ Query string
+ ResourceID string
+ Action string
+ All bool
+}
+
+// ForgetLearnings deletes rows matching the filter and returns the count
+// removed. If All is false the filter must specify at least one of
+// ResourceID or Action (the Query is the primary scoping key and is
+// always required).
+func (s *Store) ForgetLearnings(ctx context.Context, f ForgetLearningsFilter) (int64, error) {
+ if f.Query == "" {
+ return 0, fmt.Errorf("forget learnings: query is required")
+ }
+ if !f.All && f.ResourceID == "" && f.Action == "" {
+ return 0, fmt.Errorf("forget learnings: pass --resource, --action, or --all")
+ }
+ pattern := NormalizeQuery(f.Query)
+ if pattern == "" {
+ return 0, fmt.Errorf("forget learnings: query normalized to empty string")
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ clauses := []string{"query_pattern = ?"}
+ args := []any{pattern}
+ if f.ResourceID != "" {
+ clauses = append(clauses, "resource_id = ?")
+ args = append(args, f.ResourceID)
+ }
+ if f.Action != "" {
+ clauses = append(clauses, "action = ?")
+ args = append(args, f.Action)
+ }
+ q := "DELETE FROM search_learnings WHERE " + strings.Join(clauses, " AND ")
+ res, err := s.db.ExecContext(ctx, q, args...)
+ if err != nil {
+ return 0, fmt.Errorf("forget learnings: %w", err)
+ }
+ n, _ := res.RowsAffected()
+ return n, nil
+}
+
+// RecallMatch is one row returned by Recall — a learning that scored
+// above the Jaccard threshold against the supplied query.
+type RecallMatch struct {
+ LearningRow
+ MatchScore float64 `json:"match_score"`
+}
+
+// RecallOptions tunes Recall behavior.
+type RecallOptions struct {
+ // MinConfidence is the minimum confidence required for a row to
+ // appear in the results. Defaults to 1 (any row) when 0.
+ MinConfidence int
+ // Limit caps the result count. Defaults to 10 when 0.
+ Limit int
+ // JaccardMin overrides the token-set Jaccard floor. Defaults to 0.6
+ // when 0. Negative values are treated as 0 (any overlap matches).
+ JaccardMin float64
+}
+
+// Recall returns learnings whose normalized query_pattern overlaps with
+// the supplied query by token-set Jaccard >= opts.JaccardMin. Results
+// are sorted by (match_score DESC, confidence DESC, last_observed_at DESC).
+// An empty input or no match returns ([], nil) — Recall is an
+// information query, not a not-found error.
+func (s *Store) Recall(ctx context.Context, query string, opts RecallOptions) ([]RecallMatch, error) {
+ _, inputTokens := normalizeAndTokens(query)
+ if len(inputTokens) == 0 {
+ return nil, nil
+ }
+ minConf := opts.MinConfidence
+ if minConf <= 0 {
+ minConf = 1
+ }
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = defaultRecallLimit
+ }
+ jMin := opts.JaccardMin
+ if jMin == 0 {
+ jMin = defaultJaccardMin
+ }
+ if jMin < 0 {
+ jMin = 0
+ }
+
+ // Fast path: count distinct query_patterns. Stays tiny per-user, so a
+ // full scan with score-in-Go is the right tradeoff against trying to
+ // implement set-overlap in SQL.
+ rows, err := s.db.QueryContext(ctx, `SELECT id, query_pattern, COALESCE(venue, ''),
+ COALESCE(resource_type, ''), resource_id, action, COALESCE(alias_target, ''),
+ source, confidence, created_at, last_observed_at, COALESCE(notes, '')
+ FROM search_learnings
+ WHERE confidence >= ?`, minConf)
+ if err != nil {
+ return nil, fmt.Errorf("recall: %w", err)
+ }
+ defer rows.Close()
+
+ matches := make([]RecallMatch, 0)
+ for rows.Next() {
+ var r LearningRow
+ var lastObs sql.NullTime
+ if err := rows.Scan(&r.ID, &r.QueryPattern, &r.Venue, &r.ResourceType,
+ &r.ResourceID, &r.Action, &r.AliasTarget, &r.Source, &r.Confidence,
+ &r.CreatedAt, &lastObs, &r.Notes); err != nil {
+ return nil, err
+ }
+ if lastObs.Valid {
+ t := lastObs.Time
+ r.LastObservedAt = &t
+ }
+ _, rowTokens := normalizeAndTokens(r.QueryPattern)
+ score := jaccard(inputTokens, rowTokens)
+ if score < jMin {
+ continue
+ }
+ matches = append(matches, RecallMatch{LearningRow: r, MatchScore: score})
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ // Sort: match_score DESC, confidence DESC, last_observed_at DESC.
+ sortRecallMatches(matches)
+
+ if len(matches) > limit {
+ matches = matches[:limit]
+ }
+ return matches, nil
+}
+
+func sortRecallMatches(matches []RecallMatch) {
+ // Insertion sort keeps the algorithm simple; n is bounded by the
+ // per-user table size and the pre-filter on confidence.
+ for i := 1; i < len(matches); i++ {
+ for j := i; j > 0; j-- {
+ a, b := matches[j], matches[j-1]
+ if recallLess(a, b) {
+ matches[j-1], matches[j] = a, b
+ } else {
+ break
+ }
+ }
+ }
+}
+
+func recallLess(a, b RecallMatch) bool {
+ if a.MatchScore != b.MatchScore {
+ return a.MatchScore > b.MatchScore
+ }
+ if a.Confidence != b.Confidence {
+ return a.Confidence > b.Confidence
+ }
+ at := time.Time{}
+ bt := time.Time{}
+ if a.LastObservedAt != nil {
+ at = *a.LastObservedAt
+ }
+ if b.LastObservedAt != nil {
+ bt = *b.LastObservedAt
+ }
+ return at.After(bt)
+}
+
+func jaccard(a, b map[string]struct{}) float64 {
+ if len(a) == 0 || len(b) == 0 {
+ return 0
+ }
+ inter := 0
+ for k := range a {
+ if _, ok := b[k]; ok {
+ inter++
+ }
+ }
+ union := len(a) + len(b) - inter
+ if union == 0 {
+ return 0
+ }
+ return float64(inter) / float64(union)
+}
+
+// ApplyResult is what the rerank layer reports back to the caller for
+// envelope emission. Count is the number of rules that visibly touched
+// the output (boosts that moved a hit, hides that removed one, aliases
+// that swapped one).
+type ApplyResult struct {
+ Count int
+ HasHighConfidence bool
+ Warnings []string
+}
+
+// LearnedHit represents a hit synthesized from a learning whose
+// resource_id was not in the original FTS result set. The CLI layer
+// fetches the resource from the resources table by (ResourceType, ID)
+// and slots it into the output bundle at position 0 with
+// `meta.source: "learned"`.
+type LearnedHit struct {
+ ResourceType string
+ ResourceID string
+ Confidence int
+ Source string // copy of LearningRow.Source for envelope-level diagnostics
+}
+
+// Applier abstracts the in-memory hit bundle the rerank layer operates
+// on. The CLI layer implements one Applier per command (topic, compare)
+// so the layer stays decoupled from each command's hit struct shape.
+//
+// Indexing is by stable string keys the command picks — typically
+// "|" for topic, or per-pair "|" for compare.
+type Applier interface {
+ // HasHit reports whether the in-memory bundle already contains a hit
+ // keyed (resourceType, resourceID).
+ HasHit(resourceType, resourceID string) bool
+ // MoveToFront moves an existing hit to position 0 (or position N for
+ // the Nth applied boost, in the order MoveToFront is called).
+ MoveToFront(resourceType, resourceID string)
+ // InsertLearnedHit inserts a synthetic hit at position 0 for a
+ // learning whose resource the FTS layer missed entirely. The caller
+ // is responsible for fetching the underlying row from the resources
+ // table.
+ InsertLearnedHit(h LearnedHit) error
+ // RemoveHit drops a hit from the bundle.
+ RemoveHit(resourceType, resourceID string)
+ // ReplaceHit replaces (resourceType, resourceID) with the alias
+ // target. Same fetch contract as InsertLearnedHit when the alias
+ // target wasn't in the bundle.
+ ReplaceHit(srcType, srcID, dstType, dstID string) error
+}
+
+// Apply loads learnings for the supplied query and runs the
+// boost/hide/alias rules against the supplied Applier. Returns the
+// count of rules that actually touched the output, plus any warnings
+// (alias cycles, missing resources) for the caller to log.
+//
+// The match scope is: learnings whose normalized query_pattern equals
+// the current normalized query, OR is a substring match (LIKE) against
+// it. Substring widens "bitcoin" rules to fire on "bitcoin 100k".
+func (s *Store) Apply(ctx context.Context, query string, ap Applier) (ApplyResult, error) {
+ result := ApplyResult{}
+ pattern := NormalizeQuery(query)
+ if pattern == "" {
+ return result, nil
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, query_pattern, COALESCE(venue, ''), COALESCE(resource_type, ''),
+ resource_id, action, COALESCE(alias_target, ''), source, confidence,
+ created_at, last_observed_at, COALESCE(notes, '')
+ FROM search_learnings
+ WHERE query_pattern = ? OR ? LIKE '%' || query_pattern || '%'
+ ORDER BY confidence DESC, last_observed_at DESC`,
+ pattern, pattern,
+ )
+ if err != nil {
+ return result, fmt.Errorf("apply learnings: %w", err)
+ }
+ defer rows.Close()
+
+ type ruleRow struct {
+ LearningRow
+ }
+ rules := make([]ruleRow, 0)
+ for rows.Next() {
+ var r LearningRow
+ var lastObs sql.NullTime
+ if err := rows.Scan(&r.ID, &r.QueryPattern, &r.Venue, &r.ResourceType,
+ &r.ResourceID, &r.Action, &r.AliasTarget, &r.Source, &r.Confidence,
+ &r.CreatedAt, &lastObs, &r.Notes); err != nil {
+ return result, err
+ }
+ if lastObs.Valid {
+ t := lastObs.Time
+ r.LastObservedAt = &t
+ }
+ rules = append(rules, ruleRow{r})
+ }
+ if err := rows.Err(); err != nil {
+ return result, err
+ }
+
+ // Detect alias cycles before applying any rule. A simple cycle is
+ // (A -> B) + (B -> A) under the same query_pattern; drop both with
+ // a warning rather than thrash the bundle.
+ aliasGraph := make(map[string]string)
+ for _, r := range rules {
+ if r.Action == LearningActionAlias && r.AliasTarget != "" {
+ aliasGraph[r.ResourceID] = r.AliasTarget
+ }
+ }
+ cycleSrcs := make(map[string]struct{})
+ for src, dst := range aliasGraph {
+ if hop, ok := aliasGraph[dst]; ok && hop == src {
+ cycleSrcs[src] = struct{}{}
+ cycleSrcs[dst] = struct{}{}
+ }
+ }
+ if len(cycleSrcs) > 0 {
+ srcs := make([]string, 0, len(cycleSrcs))
+ for src := range cycleSrcs {
+ srcs = append(srcs, src)
+ }
+ result.Warnings = append(result.Warnings, fmt.Sprintf("alias cycle detected, dropping rules for: %s", strings.Join(srcs, ", ")))
+ }
+
+ for _, r := range rules {
+ switch r.Action {
+ case LearningActionBoost:
+ if ap.HasHit(r.ResourceType, r.ResourceID) {
+ ap.MoveToFront(r.ResourceType, r.ResourceID)
+ result.Count++
+ } else {
+ if err := ap.InsertLearnedHit(LearnedHit{
+ ResourceType: r.ResourceType,
+ ResourceID: r.ResourceID,
+ Confidence: r.Confidence,
+ Source: r.Source,
+ }); err != nil {
+ result.Warnings = append(result.Warnings, fmt.Sprintf("insert learned hit %s/%s: %v", r.ResourceType, r.ResourceID, err))
+ continue
+ }
+ result.Count++
+ }
+ if r.Confidence >= 3 {
+ result.HasHighConfidence = true
+ }
+ case LearningActionHide:
+ if ap.HasHit(r.ResourceType, r.ResourceID) {
+ ap.RemoveHit(r.ResourceType, r.ResourceID)
+ result.Count++
+ }
+ case LearningActionAlias:
+ if _, isCycle := cycleSrcs[r.ResourceID]; isCycle {
+ continue
+ }
+ if r.AliasTarget == "" {
+ continue
+ }
+ if err := ap.ReplaceHit(r.ResourceType, r.ResourceID, r.ResourceType, r.AliasTarget); err != nil {
+ result.Warnings = append(result.Warnings, fmt.Sprintf("alias replace %s -> %s: %v", r.ResourceID, r.AliasTarget, err))
+ continue
+ }
+ result.Count++
+ }
+ }
+
+ return result, nil
+}
+
+// MarshalLearnings returns the JSON envelope shape used by both
+// `learnings list` and the `recall` command for --agent output. Keeps
+// the JSON shape decoupled from any cobra-level types.
+func MarshalLearnings(rows []LearningRow) ([]byte, error) {
+ return json.Marshal(rows)
+}
diff --git a/library/productivity/human-goat/internal/store/learnings_test.go b/library/productivity/human-goat/internal/store/learnings_test.go
new file mode 100644
index 0000000000..6ed414df6f
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/learnings_test.go
@@ -0,0 +1,627 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store_test
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/printing-press-library/library/productivity/human-goat/internal/store"
+)
+
+func openLearnings(t *testing.T) *store.Store {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "learn.db")
+ s, err := store.OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func TestNormalizeQuery_StripsStopwordsAndPunctuation(t *testing.T) {
+ cases := []struct {
+ in string
+ want string
+ }{
+ {"What are the odds Portugal wins?", "portugal wins"},
+ {"portugal odds", "portugal"},
+ {" Portugal World Cup ", "portugal world cup"},
+ {"What's Portugal's chance?", "portugal chance"},
+ {"WHAT ARE THE ODDS", ""},
+ {"", ""},
+ }
+ for _, tc := range cases {
+ got := store.NormalizeQuery(tc.in)
+ if got != tc.want {
+ t.Errorf("NormalizeQuery(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestUpsertLearning_InsertsAndBumpsConfidence(t *testing.T) {
+ s := openLearnings(t)
+ in := store.UpsertLearningInput{
+ Query: "portugal world cup odds",
+ ResourceID: "KXMENWORLDCUP-26-PT",
+ ResourceType: "markets",
+ Source: store.LearningSourceTaught,
+ }
+ id1, created, err := s.UpsertLearning(context.Background(), in)
+ if err != nil {
+ t.Fatalf("upsert 1: %v", err)
+ }
+ if !created || id1 == 0 {
+ t.Fatalf("first upsert should be inserted; created=%v id=%d", created, id1)
+ }
+
+ id2, created, err := s.UpsertLearning(context.Background(), in)
+ if err != nil {
+ t.Fatalf("upsert 2: %v", err)
+ }
+ if created {
+ t.Fatalf("second upsert should be a bump, not insert")
+ }
+ if id2 != id1 {
+ t.Fatalf("re-teach should preserve row id; got %d, want %d", id2, id1)
+ }
+
+ rows, err := s.ListLearnings(context.Background(), store.ListLearningsFilter{Query: "portugal"})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("want 1 row, got %d", len(rows))
+ }
+ // Per U4, first teach lands at confidence=2 (clears the skill's
+ // skip threshold immediately); re-confirmation bumps to 3.
+ if rows[0].Confidence != 3 {
+ t.Errorf("want confidence 3 after two teaches (2 floor + 1 bump), got %d", rows[0].Confidence)
+ }
+}
+
+func TestUpsertLearning_NormalizesQueryAcrossVariants(t *testing.T) {
+ s := openLearnings(t)
+ // Two different phrasings should collapse to the same row.
+ _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "What are the odds Portugal wins?",
+ ResourceID: "KXMENWORLDCUP-26-PT",
+ })
+ if err != nil {
+ t.Fatalf("upsert 1: %v", err)
+ }
+ _, created, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal wins",
+ ResourceID: "KXMENWORLDCUP-26-PT",
+ })
+ if err != nil {
+ t.Fatalf("upsert 2: %v", err)
+ }
+ if created {
+ t.Fatalf("normalized variant should hit existing row, not insert")
+ }
+ rows, _ := s.ListLearnings(context.Background(), store.ListLearningsFilter{})
+ if len(rows) != 1 {
+ t.Fatalf("want 1 row after normalized re-teach, got %d", len(rows))
+ }
+}
+
+func TestUpsertLearning_RejectsEmptyResourceOrQuery(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{Query: "x", ResourceID: ""}); err == nil {
+ t.Errorf("want error for empty resource_id")
+ }
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{Query: "what is", ResourceID: "X"}); err == nil {
+ t.Errorf("want error when query normalizes to empty")
+ }
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{Query: "x", ResourceID: "X", Action: "bogus"}); err == nil {
+ t.Errorf("want error for invalid action")
+ }
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{Query: "x", ResourceID: "X", Action: store.LearningActionAlias}); err == nil {
+ t.Errorf("want error when alias action lacks target")
+ }
+}
+
+func TestRecall_ExactAndJaccardMatch(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup odds",
+ ResourceID: "KXMENWORLDCUP-26-PT",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup odds",
+ ResourceID: "will-portugal-win-the-2026-fifa-world-cup-912",
+ }); err != nil {
+ t.Fatalf("upsert 2: %v", err)
+ }
+
+ // Exact match.
+ matches, err := s.Recall(context.Background(), "portugal world cup odds", store.RecallOptions{})
+ if err != nil {
+ t.Fatalf("recall exact: %v", err)
+ }
+ if len(matches) != 2 {
+ t.Fatalf("exact recall: want 2 matches, got %d", len(matches))
+ }
+ if matches[0].MatchScore < 0.99 {
+ t.Errorf("exact match score should be ~1.0, got %v", matches[0].MatchScore)
+ }
+
+ // Token-overlap match: "portugal chances at the world cup" normalizes
+ // to {portugal, chances, world, cup}; vs {portugal, world, cup} the
+ // intersection is 3 and union is 4 — Jaccard 0.75 >= 0.6.
+ matches, err = s.Recall(context.Background(), "portugal chances at the world cup", store.RecallOptions{})
+ if err != nil {
+ t.Fatalf("recall overlap: %v", err)
+ }
+ if len(matches) != 2 {
+ t.Errorf("token overlap: want 2, got %d", len(matches))
+ }
+
+ // Unrelated query returns empty (information query, not error).
+ matches, err = s.Recall(context.Background(), "lakers tonight", store.RecallOptions{})
+ if err != nil {
+ t.Fatalf("recall unrelated: %v", err)
+ }
+ if len(matches) != 0 {
+ t.Errorf("unrelated query should return empty, got %d", len(matches))
+ }
+}
+
+func TestRecall_StopwordSymmetry(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "what are portugal's odds",
+ ResourceID: "KXMENWORLDCUP-26-PT",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ // Both queries normalize to "portugal". Stopword stripping makes
+ // the two queries identical at the matcher.
+ matches, err := s.Recall(context.Background(), "portugal", store.RecallOptions{})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if len(matches) != 1 {
+ t.Fatalf("stopword symmetry: want 1 match, got %d", len(matches))
+ }
+}
+
+func TestRecall_MinConfidenceAndLimit(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "A",
+ }); err != nil {
+ t.Fatalf("upsert A: %v", err)
+ }
+ // Per U4: first teach lands at conf=2. Two teaches on B bumps to 3.
+ // A stays at 2. min-confidence=3 isolates B.
+ for i := 0; i < 2; i++ {
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "B",
+ }); err != nil {
+ t.Fatalf("upsert B: %v", err)
+ }
+ }
+
+ matches, err := s.Recall(context.Background(), "portugal world cup", store.RecallOptions{MinConfidence: 3})
+ if err != nil {
+ t.Fatalf("recall min-conf: %v", err)
+ }
+ if len(matches) != 1 || matches[0].ResourceID != "B" {
+ t.Errorf("min-confidence filter failed: %#v", matches)
+ }
+
+ // Limit 1 should cap.
+ for i := 0; i < 3; i++ {
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "C" + string(rune('0'+i)),
+ }); err != nil {
+ t.Fatalf("upsert C%d: %v", i, err)
+ }
+ }
+ matches, _ = s.Recall(context.Background(), "portugal world cup", store.RecallOptions{Limit: 2})
+ if len(matches) != 2 {
+ t.Errorf("limit 2: want 2, got %d", len(matches))
+ }
+}
+
+func TestForgetLearnings(t *testing.T) {
+ s := openLearnings(t)
+ for _, rid := range []string{"X", "Y", "Z"} {
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: rid,
+ }); err != nil {
+ t.Fatalf("upsert %s: %v", rid, err)
+ }
+ }
+
+ // Without --all / --resource / --action, refuse.
+ if _, err := s.ForgetLearnings(context.Background(), store.ForgetLearningsFilter{Query: "portugal world cup"}); err == nil {
+ t.Errorf("want error when no filter provided")
+ }
+
+ // Targeted delete by resource.
+ n, err := s.ForgetLearnings(context.Background(), store.ForgetLearningsFilter{Query: "portugal world cup", ResourceID: "Y"})
+ if err != nil {
+ t.Fatalf("forget Y: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("forget Y: want 1 deleted, got %d", n)
+ }
+
+ // Wipe the rest.
+ n, err = s.ForgetLearnings(context.Background(), store.ForgetLearningsFilter{Query: "portugal world cup", All: true})
+ if err != nil {
+ t.Fatalf("forget all: %v", err)
+ }
+ if n != 2 {
+ t.Errorf("forget all: want 2 deleted, got %d", n)
+ }
+}
+
+// stubApplier is a minimal in-memory bundle for testing the rerank Apply
+// pass without depending on the topic/compare hit structs.
+type stubApplier struct {
+ hits []stubHit
+ fetched map[string]struct{} // simulated resources table
+ inserts []store.LearnedHit
+}
+
+type stubHit struct {
+ rtype string
+ rid string
+}
+
+func newStubApplier(initial []stubHit, available []stubHit) *stubApplier {
+ fetched := make(map[string]struct{})
+ for _, h := range available {
+ fetched[h.rtype+"|"+h.rid] = struct{}{}
+ }
+ return &stubApplier{hits: append([]stubHit{}, initial...), fetched: fetched}
+}
+
+func (a *stubApplier) HasHit(rt, rid string) bool {
+ for _, h := range a.hits {
+ if h.rtype == rt && h.rid == rid {
+ return true
+ }
+ }
+ return false
+}
+
+func (a *stubApplier) MoveToFront(rt, rid string) {
+ idx := -1
+ for i, h := range a.hits {
+ if h.rtype == rt && h.rid == rid {
+ idx = i
+ break
+ }
+ }
+ if idx <= 0 {
+ return
+ }
+ h := a.hits[idx]
+ a.hits = append(a.hits[:idx], a.hits[idx+1:]...)
+ a.hits = append([]stubHit{h}, a.hits...)
+}
+
+func (a *stubApplier) InsertLearnedHit(h store.LearnedHit) error {
+ a.inserts = append(a.inserts, h)
+ a.hits = append([]stubHit{{rtype: h.ResourceType, rid: h.ResourceID}}, a.hits...)
+ return nil
+}
+
+func (a *stubApplier) RemoveHit(rt, rid string) {
+ for i, h := range a.hits {
+ if h.rtype == rt && h.rid == rid {
+ a.hits = append(a.hits[:i], a.hits[i+1:]...)
+ return
+ }
+ }
+}
+
+func (a *stubApplier) ReplaceHit(srcType, srcID, dstType, dstID string) error {
+ for i, h := range a.hits {
+ if h.rtype == srcType && h.rid == srcID {
+ a.hits[i] = stubHit{rtype: dstType, rid: dstID}
+ return nil
+ }
+ }
+ // Not in hits; insert as learned.
+ return a.InsertLearnedHit(store.LearnedHit{ResourceType: dstType, ResourceID: dstID})
+}
+
+func TestApply_BoostMovesExistingHitToFront(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "KXMENWORLDCUP-26-PT",
+ ResourceType: "markets",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ ap := newStubApplier(
+ []stubHit{
+ {"markets", "KXFUSION"},
+ {"markets", "KXMENWORLDCUP-26-PT"},
+ },
+ nil,
+ )
+ res, err := s.Apply(context.Background(), "portugal world cup", ap)
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if res.Count != 1 {
+ t.Errorf("count: want 1, got %d", res.Count)
+ }
+ if ap.hits[0].rid != "KXMENWORLDCUP-26-PT" {
+ t.Errorf("boost should move ticker to front; got %#v", ap.hits)
+ }
+}
+
+func TestApply_BoostInsertsSyntheticLearnedHit(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "KXMENWORLDCUP-26-PT",
+ ResourceType: "markets",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ ap := newStubApplier(
+ []stubHit{{"markets", "KXFUSION"}},
+ nil,
+ )
+ res, err := s.Apply(context.Background(), "portugal world cup", ap)
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if res.Count != 1 || len(ap.inserts) != 1 {
+ t.Errorf("expected 1 synthetic insert; count=%d inserts=%d", res.Count, len(ap.inserts))
+ }
+ if ap.hits[0].rid != "KXMENWORLDCUP-26-PT" {
+ t.Errorf("synthetic hit should be at position 0, got %#v", ap.hits)
+ }
+}
+
+func TestApply_HideRemoves(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "KXFUSION",
+ ResourceType: "series",
+ Action: store.LearningActionHide,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ ap := newStubApplier(
+ []stubHit{
+ {"series", "KXFUSION"},
+ {"markets", "KXMENWORLDCUP-26-PT"},
+ },
+ nil,
+ )
+ res, err := s.Apply(context.Background(), "portugal world cup", ap)
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if res.Count != 1 {
+ t.Errorf("count: want 1, got %d", res.Count)
+ }
+ if len(ap.hits) != 1 || ap.hits[0].rid != "KXMENWORLDCUP-26-PT" {
+ t.Errorf("hide failed: %#v", ap.hits)
+ }
+}
+
+func TestApply_AliasReplaces(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "OLD",
+ ResourceType: "markets",
+ Action: store.LearningActionAlias,
+ AliasTarget: "NEW",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ ap := newStubApplier(
+ []stubHit{{"markets", "OLD"}},
+ nil,
+ )
+ if _, err := s.Apply(context.Background(), "portugal world cup", ap); err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if len(ap.hits) != 1 || ap.hits[0].rid != "NEW" {
+ t.Errorf("alias should rewrite OLD -> NEW; got %#v", ap.hits)
+ }
+}
+
+func TestApply_AliasCycleIsDropped(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "A",
+ ResourceType: "markets",
+ Action: store.LearningActionAlias,
+ AliasTarget: "B",
+ }); err != nil {
+ t.Fatalf("upsert A->B: %v", err)
+ }
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "B",
+ ResourceType: "markets",
+ Action: store.LearningActionAlias,
+ AliasTarget: "A",
+ }); err != nil {
+ t.Fatalf("upsert B->A: %v", err)
+ }
+ ap := newStubApplier(
+ []stubHit{
+ {"markets", "A"},
+ {"markets", "B"},
+ },
+ nil,
+ )
+ res, err := s.Apply(context.Background(), "portugal world cup", ap)
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if len(res.Warnings) == 0 {
+ t.Errorf("expected an alias-cycle warning")
+ }
+ // Hits should be unchanged.
+ if len(ap.hits) != 2 {
+ t.Errorf("cycle should leave hits intact; got %#v", ap.hits)
+ }
+}
+
+func TestApply_SubstringMatchOnQueryPattern(t *testing.T) {
+ s := openLearnings(t)
+ // Rule keyed on "bitcoin" alone.
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "bitcoin",
+ ResourceID: "KXBTCMAX100",
+ ResourceType: "series",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ ap := newStubApplier(
+ []stubHit{{"markets", "BTCETHATH-29DEC31"}},
+ nil,
+ )
+ res, err := s.Apply(context.Background(), "bitcoin 100k", ap)
+ if err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if res.Count != 1 {
+ t.Errorf("substring match should fire; count=%d", res.Count)
+ }
+ if ap.hits[0].rid != "KXBTCMAX100" {
+ t.Errorf("synthetic hit should be at front; got %#v", ap.hits)
+ }
+}
+
+func TestApply_NormalizationAtApplyTime(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "Portugal World Cup",
+ ResourceID: "KXMENWORLDCUP-26-PT",
+ ResourceType: "markets",
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ ap := newStubApplier(
+ []stubHit{{"markets", "KXMENWORLDCUP-26-PT"}},
+ nil,
+ )
+ // Different case + whitespace + stopwords on the apply side.
+ if _, err := s.Apply(context.Background(), " what are the portugal world cup ", ap); err != nil {
+ t.Fatalf("apply: %v", err)
+ }
+ if ap.hits[0].rid != "KXMENWORLDCUP-26-PT" {
+ t.Errorf("normalization mismatch; hits=%#v", ap.hits)
+ }
+}
+
+func TestListLearnings_FiltersByQueryAndSource(t *testing.T) {
+ s := openLearnings(t)
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "portugal world cup",
+ ResourceID: "A",
+ Source: store.LearningSourceTaught,
+ }); err != nil {
+ t.Fatalf("upsert A: %v", err)
+ }
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "lakers tonight",
+ ResourceID: "B",
+ Source: store.LearningSourceTaught,
+ }); err != nil {
+ t.Fatalf("upsert B: %v", err)
+ }
+
+ rows, err := s.ListLearnings(context.Background(), store.ListLearningsFilter{Query: "portugal"})
+ if err != nil {
+ t.Fatalf("list portugal: %v", err)
+ }
+ if len(rows) != 1 || rows[0].ResourceID != "A" {
+ t.Errorf("filter by query failed: %#v", rows)
+ }
+
+ rows, _ = s.ListLearnings(context.Background(), store.ListLearningsFilter{Source: store.LearningSourceTaught})
+ if len(rows) != 2 {
+ t.Errorf("filter by source: want 2, got %d", len(rows))
+ }
+}
+
+func TestNormalizeQuery_SameReferentSynonymsFold(t *testing.T) {
+ cases := []struct {
+ a, b string
+ }{
+ // Multiword variant vs canonical, with punctuation noise.
+ {"why did x win last night?", "why did x win yesterday"},
+ // Spelling variants.
+ {"plans for tonite", "plans for tonight"},
+ {"report for to-day", "report for today"},
+ }
+ for _, tc := range cases {
+ ga, gb := store.NormalizeQuery(tc.a), store.NormalizeQuery(tc.b)
+ if ga != gb || ga == "" {
+ t.Errorf("write-side fold broken: NormalizeQuery(%q)=%q vs NormalizeQuery(%q)=%q",
+ tc.a, ga, tc.b, gb)
+ }
+ }
+}
+
+func TestNormalizeQuery_TonightDoesNotFoldToYesterday(t *testing.T) {
+ got := store.NormalizeQuery("who wins tonight")
+ if got != "who wins tonight" {
+ t.Errorf("NormalizeQuery = %q, want %q (same-referent only, never across day boundaries)",
+ got, "who wins tonight")
+ }
+}
+
+func TestRegisterQuerySynonyms_DeclaredFoldsUndeclaredDoesNot(t *testing.T) {
+ // Uses a vocabulary no other test touches; registration is
+ // process-wide and additive by design (one-shot at CLI startup).
+ store.RegisterQuerySynonyms(map[string]string{"foo bar": "baz"})
+ if got, want := store.NormalizeQuery("check foo bar now"), store.NormalizeQuery("check baz now"); got != want {
+ t.Errorf("declared pair should fold: %q vs %q", got, want)
+ }
+ if got := store.NormalizeQuery("check foo qux now"); got != "check foo qux now" {
+ t.Errorf("undeclared pair must not fold: got %q", got)
+ }
+}
+
+func TestUpsertAndRecall_SynonymPhrasingSymmetry(t *testing.T) {
+ s := openLearnings(t)
+ // Teach with phrasing A ("last night"), recall with phrasing B
+ // ("yesterday"): the write-side fold keys the row under the
+ // canonical form, so the read-side fold finds it as an exact
+ // pattern match.
+ if _, _, err := s.UpsertLearning(context.Background(), store.UpsertLearningInput{
+ Query: "why did x win last night",
+ ResourceID: "resource-1",
+ Source: store.LearningSourceTaught,
+ }); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ matches, err := s.Recall(context.Background(), "why did x win yesterday", store.RecallOptions{})
+ if err != nil {
+ t.Fatalf("recall: %v", err)
+ }
+ if len(matches) != 1 || matches[0].ResourceID != "resource-1" {
+ t.Fatalf("phrasing B must recall phrasing A's learning; got %+v", matches)
+ }
+}
diff --git a/library/productivity/human-goat/internal/store/playbooks.go b/library/productivity/human-goat/internal/store/playbooks.go
new file mode 100644
index 0000000000..e2155bec64
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/playbooks.go
@@ -0,0 +1,264 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// PlaybookRow is one row in the learning_playbooks table. Either
+// PlaybookJSON or NotesText (or both) carries content; an all-empty
+// row is rejected at upsert time.
+type PlaybookRow struct {
+ ID int64 `json:"id"`
+ QueryFamily string `json:"query_family"`
+ PlaybookJSON string `json:"playbook_json,omitempty"`
+ NotesText string `json:"notes_text,omitempty"`
+ Source string `json:"source"`
+ Confidence int `json:"confidence"`
+ CreatedAt time.Time `json:"created_at"`
+ LastObservedAt *time.Time `json:"last_observed_at,omitempty"`
+}
+
+// UpsertPlaybookInput is the call-shape UpsertPlaybook accepts.
+// QueryFamily is required; at least one of PlaybookJSON / NotesText
+// must be non-empty.
+//
+// PreserveExistingNotes is a seed-loop affordance: when set, the
+// UPDATE branch only fills notes_text if the stored value is empty.
+// This protects `playbook amend` notes (which share the seed's family
+// keys) across binary upgrades that bump SeedVersion and re-run the
+// install path. Insert paths ignore the flag — a brand-new row gets
+// the supplied notes unconditionally.
+type UpsertPlaybookInput struct {
+ QueryFamily string
+ PlaybookJSON string
+ NotesText string
+ Source string
+ PreserveExistingNotes bool
+}
+
+// UpsertPlaybook inserts a playbook row for a query family or, on
+// conflict, refreshes content + bumps last_observed_at. Source on the
+// existing row is preserved across re-teaches; only the content +
+// timestamp update. Returns the row id and a bool indicating insert
+// vs. update.
+func (s *Store) UpsertPlaybook(in UpsertPlaybookInput) (int64, bool, error) {
+ family := strings.TrimSpace(in.QueryFamily)
+ if family == "" {
+ return 0, false, fmt.Errorf("upsert playbook: query_family is required")
+ }
+ if strings.TrimSpace(in.PlaybookJSON) == "" && strings.TrimSpace(in.NotesText) == "" {
+ return 0, false, fmt.Errorf("upsert playbook: at least one of playbook_json or notes_text must be non-empty")
+ }
+ source := in.Source
+ if source == "" {
+ source = LearningSourceTaught
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ now := time.Now().UTC()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, false, err
+ }
+ defer tx.Rollback()
+
+ var existingID int64
+ err = tx.QueryRow(
+ `SELECT id FROM learning_playbooks WHERE query_family = ?`,
+ family,
+ ).Scan(&existingID)
+ if err == nil {
+ // Partial-update semantics: an empty PlaybookJSON or NotesText
+ // from the caller means "leave this column alone", not "wipe
+ // it". This lets `teach-playbook --notes "..."` and
+ // `playbook amend --add-note ...` append guidance without
+ // destroying the stored choreography, and lets a playbook-only
+ // re-teach preserve existing notes. PreserveExistingNotes
+ // additionally protects existing non-empty notes_text from
+ // being overwritten by a seed-loop upsert.
+ notesUpdate := `notes_text = CASE WHEN ? != '' THEN ? ELSE notes_text END`
+ if in.PreserveExistingNotes {
+ notesUpdate = `notes_text = CASE WHEN ? != '' AND (notes_text IS NULL OR notes_text = '') THEN ? ELSE notes_text END`
+ }
+ query := `UPDATE learning_playbooks
+ SET playbook_json = CASE WHEN ? != '' THEN ? ELSE playbook_json END,
+ ` + notesUpdate + `,
+ last_observed_at = ?
+ WHERE id = ?`
+ if _, err := tx.Exec(query,
+ in.PlaybookJSON, in.PlaybookJSON,
+ in.NotesText, in.NotesText,
+ now, existingID,
+ ); err != nil {
+ return 0, false, fmt.Errorf("upsert playbook update: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, false, err
+ }
+ return existingID, false, nil
+ }
+ if err != sql.ErrNoRows {
+ return 0, false, fmt.Errorf("upsert playbook lookup: %w", err)
+ }
+
+ res, err := tx.Exec(
+ `INSERT INTO learning_playbooks
+ (query_family, playbook_json, notes_text, source, confidence, last_observed_at)
+ VALUES (?, ?, ?, ?, 2, ?)`,
+ family, in.PlaybookJSON, in.NotesText, source, now,
+ )
+ if err != nil {
+ return 0, false, fmt.Errorf("upsert playbook insert: %w", err)
+ }
+ id, _ := res.LastInsertId()
+ if err := tx.Commit(); err != nil {
+ return 0, false, err
+ }
+ return id, true, nil
+}
+
+// AppendPlaybookNotes atomically appends marker to the notes_text of
+// the row matching family. If the row doesn't exist yet, a notes-only
+// row is inserted with marker as the initial content (leading newlines
+// trimmed). Runs inside writeMu so concurrent `playbook amend` calls
+// for the same family cannot race-overwrite each other's notes.
+//
+// Returns the row id, an insert/update bool, and any error. The
+// SQLite UPDATE uses COALESCE(notes_text, ”) || ? so an absent or
+// NULL existing notes_text appends cleanly without a separate
+// read+rewrite step.
+func (s *Store) AppendPlaybookNotes(family, marker string) (int64, bool, error) {
+ family = strings.TrimSpace(family)
+ if family == "" {
+ return 0, false, fmt.Errorf("append playbook notes: query_family is required")
+ }
+ if marker == "" {
+ return 0, false, fmt.Errorf("append playbook notes: marker is required")
+ }
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ now := time.Now().UTC()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, false, err
+ }
+ defer tx.Rollback()
+
+ var existingID int64
+ err = tx.QueryRow(
+ `SELECT id FROM learning_playbooks WHERE query_family = ?`,
+ family,
+ ).Scan(&existingID)
+ switch err {
+ case nil:
+ if _, err := tx.Exec(
+ `UPDATE learning_playbooks
+ SET notes_text = COALESCE(notes_text, '') || ?,
+ last_observed_at = ?
+ WHERE id = ?`,
+ marker, now, existingID,
+ ); err != nil {
+ return 0, false, fmt.Errorf("append playbook notes update: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, false, err
+ }
+ return existingID, false, nil
+ case sql.ErrNoRows:
+ initial := strings.TrimLeft(marker, "\n")
+ res, err := tx.Exec(
+ `INSERT INTO learning_playbooks
+ (query_family, playbook_json, notes_text, source, confidence, last_observed_at)
+ VALUES (?, '', ?, ?, 2, ?)`,
+ family, initial, LearningSourceTaught, now,
+ )
+ if err != nil {
+ return 0, false, fmt.Errorf("append playbook notes insert: %w", err)
+ }
+ id, _ := res.LastInsertId()
+ if err := tx.Commit(); err != nil {
+ return 0, false, err
+ }
+ return id, true, nil
+ default:
+ return 0, false, fmt.Errorf("append playbook notes lookup: %w", err)
+ }
+}
+
+// GetPlaybookByFamily returns the row for a given family, or
+// (PlaybookRow{}, false, nil) when nothing matches. The bool
+// distinguishes "no row" from "lookup error".
+func (s *Store) GetPlaybookByFamily(family string) (PlaybookRow, bool, error) {
+ family = strings.TrimSpace(family)
+ if family == "" {
+ return PlaybookRow{}, false, nil
+ }
+ var row PlaybookRow
+ var playbook, notes sql.NullString
+ var lastObserved sql.NullTime
+ err := s.db.QueryRow(
+ `SELECT id, query_family, COALESCE(playbook_json, ''), COALESCE(notes_text, ''),
+ source, confidence, created_at, last_observed_at
+ FROM learning_playbooks WHERE query_family = ?`,
+ family,
+ ).Scan(&row.ID, &row.QueryFamily, &playbook, ¬es, &row.Source, &row.Confidence, &row.CreatedAt, &lastObserved)
+ if err == sql.ErrNoRows {
+ return PlaybookRow{}, false, nil
+ }
+ if err != nil {
+ return PlaybookRow{}, false, fmt.Errorf("get playbook: %w", err)
+ }
+ row.PlaybookJSON = playbook.String
+ row.NotesText = notes.String
+ if lastObserved.Valid {
+ t := lastObserved.Time
+ row.LastObservedAt = &t
+ }
+ return row, true, nil
+}
+
+// ListPlaybooks returns all rows ordered by last_observed_at DESC,
+// excluding any sentinel/meta rows whose family starts with "__"
+// (e.g. the seed-version tracker "__seed_meta__"). Useful for
+// `playbook list` inspection without leaking install bookkeeping into
+// agent-facing JSON.
+func (s *Store) ListPlaybooks() ([]PlaybookRow, error) {
+ rows, err := s.db.Query(
+ `SELECT id, query_family, COALESCE(playbook_json, ''), COALESCE(notes_text, ''),
+ source, confidence, created_at, last_observed_at
+ FROM learning_playbooks
+ WHERE query_family NOT LIKE '\_\_%' ESCAPE '\'
+ ORDER BY COALESCE(last_observed_at, created_at) DESC`,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("list playbooks: %w", err)
+ }
+ defer rows.Close()
+ var out []PlaybookRow
+ for rows.Next() {
+ var row PlaybookRow
+ var playbook, notes sql.NullString
+ var lastObserved sql.NullTime
+ if err := rows.Scan(&row.ID, &row.QueryFamily, &playbook, ¬es, &row.Source, &row.Confidence, &row.CreatedAt, &lastObserved); err != nil {
+ return nil, fmt.Errorf("scan playbook: %w", err)
+ }
+ row.PlaybookJSON = playbook.String
+ row.NotesText = notes.String
+ if lastObserved.Valid {
+ t := lastObserved.Time
+ row.LastObservedAt = &t
+ }
+ out = append(out, row)
+ }
+ return out, rows.Err()
+}
diff --git a/library/productivity/human-goat/internal/store/playbooks_test.go b/library/productivity/human-goat/internal/store/playbooks_test.go
new file mode 100644
index 0000000000..a274fdb515
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/playbooks_test.go
@@ -0,0 +1,426 @@
+// Copyright 2026 mvanhorn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+func openTestPlaybooksStore(t *testing.T) *Store {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "playbooks.db")
+ s, err := OpenWithContext(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func TestUpsertPlaybook_Happy(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ id, inserted, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "doing end led ppg rpg season spg",
+ PlaybookJSON: `{"steps":[{"cmd":"teams basketball nba {team.id}"}]}`,
+ NotesText: "byathlete needs seasontype=2; categories has dup labels",
+ })
+ if err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ if !inserted {
+ t.Errorf("first insert should report inserted=true")
+ }
+ if id <= 0 {
+ t.Errorf("want positive id, got %d", id)
+ }
+
+ row, ok, err := s.GetPlaybookByFamily("doing end led ppg rpg season spg")
+ if err != nil || !ok {
+ t.Fatalf("get: err=%v ok=%v", err, ok)
+ }
+ if !strings.Contains(row.PlaybookJSON, "byathlete") && !strings.Contains(row.PlaybookJSON, "teams basketball") {
+ t.Errorf("playbook content mismatch: %q", row.PlaybookJSON)
+ }
+ if !strings.Contains(row.NotesText, "seasontype=2") {
+ t.Errorf("notes content mismatch: %q", row.NotesText)
+ }
+}
+
+func TestUpsertPlaybook_Idempotent(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ for i := 0; i < 3; i++ {
+ _, inserted, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-x",
+ PlaybookJSON: `{"steps":[]}`,
+ NotesText: "v" + string(rune('a'+i)),
+ })
+ if err != nil {
+ t.Fatalf("upsert %d: %v", i, err)
+ }
+ if i == 0 && !inserted {
+ t.Errorf("first call should insert")
+ }
+ if i > 0 && inserted {
+ t.Errorf("call %d should update not insert", i)
+ }
+ }
+
+ rows, err := s.ListPlaybooks()
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ famXCount := 0
+ for _, r := range rows {
+ if r.QueryFamily == "fam-x" {
+ famXCount++
+ }
+ }
+ if famXCount != 1 {
+ t.Errorf("want 1 row for fam-x, got %d", famXCount)
+ }
+}
+
+func TestUpsertPlaybook_RejectsEmptyContent(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-empty",
+ })
+ if err == nil {
+ t.Fatal("expected error when both playbook_json and notes_text are empty")
+ }
+}
+
+func TestUpsertPlaybook_RejectsEmptyFamily(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: " ",
+ NotesText: "nope",
+ })
+ if err == nil {
+ t.Fatal("expected error when query_family is empty/whitespace")
+ }
+}
+
+func TestUpsertPlaybook_NotesOnly(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-notes-only",
+ NotesText: "remember the seasontype hack",
+ })
+ if err != nil {
+ t.Fatalf("notes-only upsert should succeed: %v", err)
+ }
+ row, ok, _ := s.GetPlaybookByFamily("fam-notes-only")
+ if !ok {
+ t.Fatal("row missing")
+ }
+ if row.PlaybookJSON != "" {
+ t.Errorf("playbook_json should be empty; got %q", row.PlaybookJSON)
+ }
+ if row.NotesText == "" {
+ t.Errorf("notes_text should be populated")
+ }
+}
+
+func TestUpsertPlaybook_PreservesUnsuppliedFields(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ // Seed both columns.
+ _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-preserve",
+ PlaybookJSON: `{"steps":[{"cmd":"seasons"}]}`,
+ NotesText: "original gotchas",
+ })
+ if err != nil {
+ t.Fatalf("seed upsert: %v", err)
+ }
+
+ // Notes-only re-teach: playbook_json must survive.
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-preserve",
+ NotesText: "amended gotcha",
+ }); err != nil {
+ t.Fatalf("notes-only update: %v", err)
+ }
+ row, _, _ := s.GetPlaybookByFamily("fam-preserve")
+ if !strings.Contains(row.PlaybookJSON, "seasons") {
+ t.Errorf("notes-only update wiped playbook_json: %q", row.PlaybookJSON)
+ }
+ if row.NotesText != "amended gotcha" {
+ t.Errorf("notes_text was not updated: %q", row.NotesText)
+ }
+
+ // Playbook-only re-teach: notes_text must survive.
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-preserve",
+ PlaybookJSON: `{"steps":[{"cmd":"newcmd"}]}`,
+ }); err != nil {
+ t.Fatalf("playbook-only update: %v", err)
+ }
+ row, _, _ = s.GetPlaybookByFamily("fam-preserve")
+ if !strings.Contains(row.PlaybookJSON, "newcmd") {
+ t.Errorf("playbook-only update did not refresh playbook_json: %q", row.PlaybookJSON)
+ }
+ if row.NotesText != "amended gotcha" {
+ t.Errorf("playbook-only update wiped notes_text: %q", row.NotesText)
+ }
+}
+
+func TestUpsertPlaybook_PreserveExistingNotes(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ // Seed first.
+ _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-seed",
+ PlaybookJSON: `{"steps":[{"cmd":"initial"}]}`,
+ NotesText: "embedded notes v1",
+ })
+ if err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ // Agent appends a gotcha via amend (uses partial-update semantics).
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-seed",
+ NotesText: "agent-authored gotcha",
+ }); err != nil {
+ t.Fatalf("amend: %v", err)
+ }
+
+ // Binary upgrade re-runs the seed loop with PreserveExistingNotes
+ // set. PlaybookJSON refreshes; NotesText stays as the agent wrote.
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-seed",
+ PlaybookJSON: `{"steps":[{"cmd":"v2"}]}`,
+ NotesText: "embedded notes v2",
+ PreserveExistingNotes: true,
+ }); err != nil {
+ t.Fatalf("re-seed: %v", err)
+ }
+ row, _, _ := s.GetPlaybookByFamily("fam-seed")
+ if !strings.Contains(row.PlaybookJSON, "v2") {
+ t.Errorf("re-seed did not refresh playbook_json: %q", row.PlaybookJSON)
+ }
+ if row.NotesText != "agent-authored gotcha" {
+ t.Errorf("re-seed clobbered agent notes: %q", row.NotesText)
+ }
+}
+
+func TestUpsertPlaybook_PreserveExistingNotes_EmptyFills(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ // Seed playbook only (no notes).
+ _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-empty-notes",
+ PlaybookJSON: `{"steps":[{"cmd":"x"}]}`,
+ })
+ if err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ // First binary that ships embedded notes: should fill the empty
+ // notes column because there's nothing to preserve yet.
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "fam-empty-notes",
+ PlaybookJSON: `{"steps":[{"cmd":"x"}]}`,
+ NotesText: "first canonical notes",
+ PreserveExistingNotes: true,
+ }); err != nil {
+ t.Fatalf("re-seed: %v", err)
+ }
+ row, _, _ := s.GetPlaybookByFamily("fam-empty-notes")
+ if row.NotesText != "first canonical notes" {
+ t.Errorf("seed should have filled empty notes_text: %q", row.NotesText)
+ }
+}
+
+func TestUpsertPlaybook_Concurrent(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 5; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, _, _ = s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "race-fam",
+ NotesText: "concurrent",
+ })
+ }()
+ }
+ wg.Wait()
+
+ rows, _ := s.ListPlaybooks()
+ count := 0
+ for _, r := range rows {
+ if r.QueryFamily == "race-fam" {
+ count++
+ }
+ }
+ if count != 1 {
+ t.Errorf("concurrent upserts should produce exactly 1 row, got %d", count)
+ }
+}
+
+func TestGetPlaybookByFamily_NotFound(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+ _, ok, err := s.GetPlaybookByFamily("never-taught")
+ if err != nil {
+ t.Fatalf("get nonexistent: %v", err)
+ }
+ if ok {
+ t.Error("ok should be false for missing family")
+ }
+}
+
+func TestAppendPlaybookNotes_InsertsWhenAbsent(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+ id, inserted, err := s.AppendPlaybookNotes("season recap mariners", "\n\n[amend 2026-05-25T03:14Z]: first")
+ if err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ if !inserted || id <= 0 {
+ t.Fatalf("want inserted=true and positive id; got inserted=%v id=%d", inserted, id)
+ }
+ row, ok, err := s.GetPlaybookByFamily("season recap mariners")
+ if err != nil || !ok {
+ t.Fatalf("get: ok=%v err=%v", ok, err)
+ }
+ if row.NotesText != "[amend 2026-05-25T03:14Z]: first" {
+ t.Errorf("leading newlines should be trimmed on insert; got %q", row.NotesText)
+ }
+}
+
+func TestAppendPlaybookNotes_AppendsToExisting(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "test family",
+ PlaybookJSON: `{"steps":[]}`,
+ NotesText: "base notes",
+ }); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if _, _, err := s.AppendPlaybookNotes("test family", "\n\n[amend 2026-05-25T03:14Z]: addition"); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ row, _, _ := s.GetPlaybookByFamily("test family")
+ if !strings.Contains(row.NotesText, "base notes") {
+ t.Errorf("base notes lost; got %q", row.NotesText)
+ }
+ if !strings.Contains(row.NotesText, "[amend 2026-05-25T03:14Z]: addition") {
+ t.Errorf("marker missing; got %q", row.NotesText)
+ }
+ if !strings.HasPrefix(row.NotesText, "base notes\n\n[amend ") {
+ t.Errorf("expected marker appended to base; got %q", row.NotesText)
+ }
+}
+
+func TestAppendPlaybookNotes_ConcurrentNoLoss(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+ // Seed an empty-notes row so we exercise the UPDATE branch under
+ // concurrent appends — the bug Greptile flagged is that two amends
+ // can interleave outside the transaction and overwrite each other.
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "concurrent family",
+ PlaybookJSON: `{"steps":[]}`,
+ NotesText: "seed",
+ }); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ const N = 8
+ var wg sync.WaitGroup
+ for i := 0; i < N; i++ {
+ i := i
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ marker := "\n\n[amend 2026-05-25T03:14Z]: note-" + string(rune('A'+i))
+ if _, _, err := s.AppendPlaybookNotes("concurrent family", marker); err != nil {
+ t.Errorf("append %d: %v", i, err)
+ }
+ }()
+ }
+ wg.Wait()
+ row, _, _ := s.GetPlaybookByFamily("concurrent family")
+ for i := 0; i < N; i++ {
+ want := "note-" + string(rune('A'+i))
+ if !strings.Contains(row.NotesText, want) {
+ t.Errorf("concurrent append lost %q; final notes=%q", want, row.NotesText)
+ }
+ }
+}
+
+func TestListPlaybooks_HidesSentinelFamilies(t *testing.T) {
+ t.Parallel()
+ s := openTestPlaybooksStore(t)
+ // Insert a sentinel-shaped row and a normal row.
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "__seed_meta__",
+ NotesText: "v1",
+ }); err != nil {
+ t.Fatalf("sentinel upsert: %v", err)
+ }
+ if _, _, err := s.UpsertPlaybook(UpsertPlaybookInput{
+ QueryFamily: "real family",
+ PlaybookJSON: `{"steps":[]}`,
+ NotesText: "notes",
+ }); err != nil {
+ t.Fatalf("real upsert: %v", err)
+ }
+ rows, err := s.ListPlaybooks()
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ for _, r := range rows {
+ if strings.HasPrefix(r.QueryFamily, "__") {
+ t.Errorf("ListPlaybooks should hide __-prefixed sentinel rows; got %q", r.QueryFamily)
+ }
+ }
+ // Direct lookup should still find the sentinel.
+ if _, ok, err := s.GetPlaybookByFamily("__seed_meta__"); err != nil || !ok {
+ t.Errorf("GetPlaybookByFamily should still see sentinel; ok=%v err=%v", ok, err)
+ }
+}
+
+func TestPlaybooksTable_LegacyDBHeal(t *testing.T) {
+ t.Parallel()
+ // New store creates the table from scratch (the table didn't exist
+ // before this plan landed, so any pre-existing user DB will hit this
+ // path on first Open after upgrade). Verify the table exists post-Open.
+ s := openTestPlaybooksStore(t)
+ var name string
+ err := s.DB().QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='learning_playbooks'`,
+ ).Scan(&name)
+ if err != nil {
+ t.Fatalf("learning_playbooks table should exist after Open: %v", err)
+ }
+ if name != "learning_playbooks" {
+ t.Errorf("got table name %q", name)
+ }
+}
diff --git a/library/productivity/human-goat/internal/store/schema_version_test.go b/library/productivity/human-goat/internal/store/schema_version_test.go
new file mode 100644
index 0000000000..249182daef
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/schema_version_test.go
@@ -0,0 +1,1592 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// TestSchemaVersion_StampedOnFreshDB verifies that opening a brand-new
+// database stamps the current schema version. This is the contract that
+// makes StoreSchemaVersion upgrades safe: every freshly-created DB
+// records the version it was built under.
+func TestSchemaVersion_StampedOnFreshDB(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open fresh db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("fresh db version = %d, want %d", v, StoreSchemaVersion)
+ }
+}
+
+// TestOpenAppliesPragmas pins the connection-string contract: the store
+// must open in WAL journal mode with a non-zero busy_timeout so a read
+// concurrent with a write waits on the lock instead of failing immediately
+// with SQLITE_BUSY. It fails the instant the DSN regresses to the mattn-
+// style _journal_mode=WAL form, which modernc.org/sqlite silently drops —
+// see the OpenReadOnly comment for the driver-syntax detail.
+func TestOpenAppliesPragmas(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ requirePragma(t, s.DB(), "journal_mode", "wal")
+ requirePragma(t, s.DB(), "busy_timeout", "5000")
+
+ // The read-only handle (MCP sql/search, analytics) must see the same WAL
+ // file mode and carry the busy_timeout so it waits on a concurrent writer
+ // rather than erroring.
+ ro, err := OpenReadOnly(dbPath)
+ if err != nil {
+ t.Fatalf("open read-only: %v", err)
+ }
+ defer ro.Close()
+
+ requirePragma(t, ro.DB(), "journal_mode", "wal")
+ requirePragma(t, ro.DB(), "busy_timeout", "5000")
+}
+
+// requirePragma fails the test unless `PRAGMA ` reports want. It reads
+// the value as text so one helper covers both string pragmas (journal_mode)
+// and integer pragmas (busy_timeout).
+func requirePragma(t *testing.T, db *sql.DB, name, want string) {
+ t.Helper()
+ var got string
+ if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil {
+ t.Fatalf("read pragma %s: %v", name, err)
+ }
+ if got != want {
+ t.Fatalf("PRAGMA %s = %q, want %q", name, got, want)
+ }
+}
+
+// TestOpenReadOnly_DeleteModeDBDoesNotWrite guards the read-only DSN against
+// mutating the database. journal_mode is a file-level property set by the
+// read-write open, not the connection — issuing PRAGMA journal_mode=WAL on a
+// read-only handle to a DB still in the default delete journal mode (a pre-WAL
+// database from an older binary, read before its first read-write open) fails
+// with "attempt to write a readonly database". OpenReadOnly must read such a
+// DB without error, so its DSN carries no journal_mode pragma.
+func TestOpenReadOnly_DeleteModeDBDoesNotWrite(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Create a delete-journal-mode DB directly (no WAL conversion), mirroring
+ // a database written by a binary that predated the WAL DSN.
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE resources (id TEXT)`); err != nil {
+ t.Fatalf("seed table: %v", err)
+ }
+ raw.Close()
+
+ ro, err := OpenReadOnly(dbPath)
+ if err != nil {
+ t.Fatalf("open read-only: %v", err)
+ }
+ defer ro.Close()
+
+ var n int
+ if err := ro.DB().QueryRow(`SELECT count(*) FROM resources`).Scan(&n); err != nil {
+ t.Fatalf("read-only query on delete-mode DB: %v", err)
+ }
+}
+
+// TestSchemaVersion_StampExistingZeroDB verifies the stamp-and-continue
+// rule for existing deployed databases. A DB that predates the gate has
+// user_version = 0; opening it with this binary should stamp the version
+// to StoreSchemaVersion without touching any data.
+func TestSchemaVersion_StampExistingZeroDB(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Pre-create the DB with user_version = 0 and no tables, simulating
+ // a database created by a pre-gate version of the binary before any
+ // migrations ran.
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 0`); err != nil {
+ t.Fatalf("stamp zero: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open pre-gate db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("post-stamp version = %d, want %d", v, StoreSchemaVersion)
+ }
+}
+
+// TestSchemaVersion_RefusesNewerDB verifies fail-fast when the on-disk
+// schema is newer than the binary supports. Without this gate, a user
+// who upgrades their library but not their binary would hit silent
+// "no such column" errors instead of a clear version mismatch.
+func TestSchemaVersion_RefusesNewerDB(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 999`); err != nil {
+ t.Fatalf("stamp future version: %v", err)
+ }
+ raw.Close()
+
+ _, err = Open(dbPath)
+ if err == nil {
+ t.Fatalf("expected open to fail on newer schema, got nil")
+ }
+}
+
+// TestMigrate_ConcurrentFreshDB exercises the BEGIN IMMEDIATE migration
+// transaction. Without it, N goroutines opening the same fresh DB in
+// parallel race per CREATE TABLE statement and trip SQLITE_BUSY despite
+// the busy_timeout. With it, they serialize on the RESERVED lock
+// acquired at BEGIN time and every Open succeeds.
+func TestMigrate_ConcurrentFreshDB(t *testing.T) {
+ if testing.Short() {
+ t.Skip("concurrent migration test can take up to migrationLockTimeout under contention")
+ }
+ t.Parallel()
+
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ const n = 8
+ errs := make(chan error, n)
+ var wg sync.WaitGroup
+ wg.Add(n)
+ for i := 0; i < n; i++ {
+ go func() {
+ defer wg.Done()
+ s, err := Open(dbPath)
+ if err != nil {
+ errs <- err
+ return
+ }
+ s.Close()
+ }()
+ }
+ wg.Wait()
+ close(errs)
+
+ for err := range errs {
+ t.Fatalf("concurrent Open failed: %v", err)
+ }
+}
+
+// holdWriteLock takes an exclusive write lock on dbPath that a peer's
+// BEGIN IMMEDIATE cannot acquire until the returned cleanup runs. Used
+// to construct contention scenarios in the migration tests.
+func holdWriteLock(t *testing.T, dbPath string) (cleanup func()) {
+ t.Helper()
+ holder, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
+ if err != nil {
+ t.Fatalf("open holder: %v", err)
+ }
+ htx, err := holder.Begin()
+ if err != nil {
+ _ = holder.Close()
+ t.Fatalf("begin holder tx: %v", err)
+ }
+ if _, err := htx.Exec(`CREATE TABLE IF NOT EXISTS holder_lock (id INTEGER)`); err != nil {
+ _ = htx.Rollback()
+ _ = holder.Close()
+ t.Fatalf("seed holder write: %v", err)
+ }
+ return func() {
+ _ = htx.Rollback()
+ _ = holder.Close()
+ }
+}
+
+// TestOpenWithContext_RespectsCancellation verifies that a caller that
+// cancels its context during a stalled migration sees the cancellation
+// surface as the returned error within a short window, instead of
+// having to wait out the full migrationLockTimeout. SIGINT in a Cobra
+// command's context must interrupt store.Open, not just block on it.
+func TestOpenWithContext_RespectsCancellation(t *testing.T) {
+ t.Parallel()
+
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ defer holdWriteLock(t, dbPath)()
+
+ // Pre-cancel the context. The migration's BEGIN IMMEDIATE will BUSY
+ // against the holder; the very first iteration of retryOnBusy then
+ // hits the ctx.Done() arm of its select and propagates ctx.Canceled.
+ // A blocked-then-cancel pattern using time.Sleep would prove the
+ // same property but cost the sleep interval on every CI run.
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ start := time.Now()
+ _, err := OpenWithContext(ctx, dbPath)
+ elapsed := time.Since(start)
+
+ if err == nil {
+ t.Fatalf("expected OpenWithContext to fail under contention with cancelled ctx")
+ }
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected context.Canceled in error chain, got: %v", err)
+ }
+ // Without ctx threading this would block until migrationLockTimeout
+ // (default 30s). 5s is generous headroom over the actual return
+ // time (microseconds for a pre-cancelled ctx) without flaking CI.
+ if elapsed > 5*time.Second {
+ t.Fatalf("OpenWithContext returned after %s; pre-cancelled ctx should short-circuit immediately", elapsed)
+ }
+}
+
+// TestMigrate_RejectsNewerDBImmediately verifies that an old binary
+// opening a newer-schema DB rejects fast even when a peer migrator is
+// still holding the write lock. The schema-version check runs on the
+// pinned connection BEFORE BEGIN IMMEDIATE so the rejection path
+// doesn't have to wait out the migration lock.
+func TestMigrate_RejectsNewerDBImmediately(t *testing.T) {
+ t.Parallel()
+
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Pre-stamp the DB at a version this binary doesn't support.
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 999`); err != nil {
+ t.Fatalf("stamp future version: %v", err)
+ }
+ raw.Close()
+
+ defer holdWriteLock(t, dbPath)()
+
+ start := time.Now()
+ _, err = Open(dbPath)
+ elapsed := time.Since(start)
+
+ if err == nil {
+ t.Fatalf("expected Open to refuse a newer-schema DB")
+ }
+ // The fast-path goal: rejection must arrive well under
+ // migrationLockTimeout. 5s leaves headroom over the WAL init race
+ // (a few ms in practice) without being so tight CI flakes.
+ if elapsed > 5*time.Second {
+ t.Fatalf("Open rejected after %s; fast-path should reject in well under migrationLockTimeout (30s)", elapsed)
+ }
+}
+
+// TestSchemaVersion_ReopenIsIdempotent verifies that opening an already
+// correctly-stamped DB is a no-op — the second open reads the version
+// and the migrations are all idempotent (CREATE TABLE IF NOT EXISTS).
+func TestSchemaVersion_ReopenIsIdempotent(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ s1, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("first open: %v", err)
+ }
+ s1.Close()
+
+ s2, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("second open: %v", err)
+ }
+ defer s2.Close()
+
+ v, err := s2.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("reopened version = %d, want %d", v, StoreSchemaVersion)
+ }
+}
+
+func TestResources_CompositeKeyPreservesOverlappingIDs(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer s.Close()
+
+ if err := s.Upsert("biz", "shared", []byte(`{"kind":"biz","name":"Pinky restaurant"}`)); err != nil {
+ t.Fatalf("upsert biz: %v", err)
+ }
+ if err := s.Upsert("bookmark", "shared", []byte(`{"kind":"bookmark","note":"anniversary"}`)); err != nil {
+ t.Fatalf("upsert bookmark: %v", err)
+ }
+
+ biz, err := s.Get("biz", "shared")
+ if err != nil {
+ t.Fatalf("get biz: %v", err)
+ }
+ if string(biz) != `{"kind":"biz","name":"Pinky restaurant"}` {
+ t.Fatalf("biz payload = %s", biz)
+ }
+
+ bookmark, err := s.Get("bookmark", "shared")
+ if err != nil {
+ t.Fatalf("get bookmark: %v", err)
+ }
+ if string(bookmark) != `{"kind":"bookmark","note":"anniversary"}` {
+ t.Fatalf("bookmark payload = %s", bookmark)
+ }
+
+ var count int
+ if err := s.DB().QueryRow(`SELECT COUNT(*) FROM resources WHERE id = 'shared'`).Scan(&count); err != nil {
+ t.Fatalf("count overlapping rows: %v", err)
+ }
+ if count != 2 {
+ t.Fatalf("overlapping row count = %d, want 2", count)
+ }
+
+ matches, err := s.Search("restaurant", 10)
+ if err != nil {
+ t.Fatalf("search restaurant: %v", err)
+ }
+ if len(matches) != 1 || string(matches[0]) != `{"kind":"biz","name":"Pinky restaurant"}` {
+ t.Fatalf("restaurant search = %q, want only biz payload", matches)
+ }
+}
+
+// Callers detect missing rows via errors.Is(err, sql.ErrNoRows); present
+// rows return the JSON payload with a nil error.
+func TestGet_MissingRowReturnsErrNoRows(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ data, err := s.Get("missing_type", "missing_id")
+ if !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("Get missing row err = %v, want sql.ErrNoRows", err)
+ }
+ if data != nil {
+ t.Fatalf("Get missing row data = %s, want nil", data)
+ }
+
+ if err := s.Upsert("present_type", "present_id", []byte(`{"ok":true}`)); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ got, err := s.Get("present_type", "present_id")
+ if err != nil {
+ t.Fatalf("Get present row: %v", err)
+ }
+ if string(got) != `{"ok":true}` {
+ t.Fatalf("Get present row data = %s, want {\"ok\":true}", got)
+ }
+}
+
+func TestMigrate_ResourcesCompositeKeyUpgrade(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE resources (
+ id TEXT PRIMARY KEY,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v1 resources: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE VIRTUAL TABLE resources_fts USING fts5(
+ id, resource_type, content, tokenize='porter unicode61'
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v1 resources_fts: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('shared', 'biz', '{"kind":"biz","name":"legacy restaurant"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("insert v1 resource: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (1, 'shared', 'biz', '{"kind":"biz","name":"legacy restaurant"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("insert v1 fts row: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 1`); err != nil {
+ raw.Close()
+ t.Fatalf("stamp v1: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("upgraded version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ rows, err := s.DB().Query(`PRAGMA table_info(resources)`)
+ if err != nil {
+ t.Fatalf("table_info resources: %v", err)
+ }
+ defer rows.Close()
+
+ pk := map[string]int{}
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pkOrder int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pkOrder); err != nil {
+ t.Fatalf("scan table_info: %v", err)
+ }
+ pk[name] = pkOrder
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("table_info rows: %v", err)
+ }
+ if pk["resource_type"] != 1 || pk["id"] != 2 {
+ t.Fatalf("resources primary key order = resource_type:%d id:%d, want resource_type:1 id:2", pk["resource_type"], pk["id"])
+ }
+
+ if err := s.Upsert("bookmark", "shared", []byte(`{"kind":"bookmark","note":"after upgrade"}`)); err != nil {
+ t.Fatalf("upsert overlapping resource after upgrade: %v", err)
+ }
+
+ biz, err := s.Get("biz", "shared")
+ if err != nil {
+ t.Fatalf("get migrated biz: %v", err)
+ }
+ if string(biz) != `{"kind":"biz","name":"legacy restaurant"}` {
+ t.Fatalf("migrated biz payload = %s", biz)
+ }
+
+ bookmark, err := s.Get("bookmark", "shared")
+ if err != nil {
+ t.Fatalf("get upgraded bookmark: %v", err)
+ }
+ if string(bookmark) != `{"kind":"bookmark","note":"after upgrade"}` {
+ t.Fatalf("upgraded bookmark payload = %s", bookmark)
+ }
+
+ matches, err := s.Search("legacy", 10)
+ if err != nil {
+ t.Fatalf("search migrated fts: %v", err)
+ }
+ if len(matches) != 1 || string(matches[0]) != `{"kind":"biz","name":"legacy restaurant"}` {
+ t.Fatalf("legacy search = %q, want migrated biz payload", matches)
+ }
+}
+
+func TestMigrate_V2ResourcesFTSRowIDUpgrade(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v2 resources: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE VIRTUAL TABLE resources_fts USING fts5(
+ id, resource_type, content, tokenize='porter unicode61'
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create stale resources_fts: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('shared', 'biz', '{"kind":"biz","name":"legacy restaurant"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("seed v2 resource: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (1, 'shared', 'biz', '{"kind":"biz","name":"legacy restaurant"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("seed stale resources_fts row: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 2`); err != nil {
+ raw.Close()
+ t.Fatalf("stamp v2: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("upgraded version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ var count int
+ if err := s.DB().QueryRow(`SELECT COUNT(*) FROM resources_fts WHERE id = 'shared' AND resource_type = 'biz'`).Scan(&count); err != nil {
+ t.Fatalf("count rebuilt resources_fts rows: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("resources_fts row count = %d, want 1", count)
+ }
+
+ var rowid int64
+ if err := s.DB().QueryRow(`SELECT rowid FROM resources_fts WHERE id = 'shared' AND resource_type = 'biz'`).Scan(&rowid); err != nil {
+ t.Fatalf("read rebuilt resources_fts rowid: %v", err)
+ }
+ if want := ftsRowID("biz", "shared"); rowid != want {
+ t.Fatalf("resources_fts rowid = %d, want %d", rowid, want)
+ }
+
+ data, err := s.Get("biz", "shared")
+ if err != nil {
+ t.Fatalf("get preserved v2 resource after rowid migration: %v", err)
+ }
+ if string(data) != `{"kind":"biz","name":"legacy restaurant"}` {
+ t.Fatalf("preserved v2 resource payload = %s, want original", data)
+ }
+
+ if err := s.Upsert("biz", "shared", []byte(`{"kind":"biz","name":"legacy cafe"}`)); err != nil {
+ t.Fatalf("upsert after rowid migration: %v", err)
+ }
+ matches, err := s.Search("legacy", 10)
+ if err != nil {
+ t.Fatalf("search rebuilt fts: %v", err)
+ }
+ if len(matches) != 1 || string(matches[0]) != `{"kind":"biz","name":"legacy cafe"}` {
+ t.Fatalf("legacy search = %q, want exactly one updated payload", matches)
+ }
+}
+
+func TestMigrate_V3ResourcesFTSRebuildsSearchableContent(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v3 resources: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE VIRTUAL TABLE resources_fts USING fts5(
+ id, resource_type, content, tokenize='porter unicode61'
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v3 resources_fts: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('shared', 'biz', '{"kind":"biz","name":"canonical resource"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("seed v3 resource: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (?, 'shared', 'biz', '{"kind":"biz","name":"sentinel fts"}')`, ftsRowID("biz", "shared")); err != nil {
+ raw.Close()
+ t.Fatalf("seed v3 resources_fts row: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 3`); err != nil {
+ raw.Close()
+ t.Fatalf("stamp v3: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open v3 db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("schema version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ var content string
+ if err := s.DB().QueryRow(`SELECT content FROM resources_fts WHERE id = 'shared' AND resource_type = 'biz'`).Scan(&content); err != nil {
+ t.Fatalf("read resources_fts content: %v", err)
+ }
+ if strings.Contains(content, "sentinel") || strings.Contains(content, "name") || !strings.Contains(content, "canonical resource") {
+ t.Fatalf("resources_fts content = %s, want rebuilt searchable values only", content)
+ }
+}
+
+func TestMigrate_ResourcesFTSContentSchemaVersionNoRebuild(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create resources: %v", err)
+ }
+ if _, err := raw.Exec(resourcesFTSCreateSQL); err != nil {
+ raw.Close()
+ t.Fatalf("create resources_fts: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('shared', 'biz', '{"kind":"biz","name":"canonical resource"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("seed resource: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (?, 'shared', 'biz', 'sentinel fts')`, ftsRowID("biz", "shared")); err != nil {
+ raw.Close()
+ t.Fatalf("seed resources_fts row: %v", err)
+ }
+ if _, err := raw.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, resourcesFTSContentSchemaVersion)); err != nil {
+ raw.Close()
+ t.Fatalf("stamp resources fts content schema version: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer s.Close()
+
+ var content string
+ if err := s.DB().QueryRow(`SELECT content FROM resources_fts WHERE id = 'shared' AND resource_type = 'biz'`).Scan(&content); err != nil {
+ t.Fatalf("read resources_fts content: %v", err)
+ }
+ if content != "sentinel fts" {
+ t.Fatalf("resources_fts content = %s, want sentinel row preserved", content)
+ }
+}
+
+// TestMigrate_V3ToV6IsAdditive verifies that opening a v3-stamped database
+// preserves existing resources rows after the learn-table migrations run.
+// The learn migrations only add tables when the spec enables them — never
+// drop or rewrite resources. Without this regression coverage, a future
+// refactor that turns the additive step into a destructive one would silently
+// nuke every existing library CLI's local data on first reopen.
+func TestMigrate_V3ToV6IsAdditive(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v2 resources: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('user-1', 'user', '{"id":"user-1","name":"alice"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("seed v2 row: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 3`); err != nil {
+ raw.Close()
+ t.Fatalf("stamp v3: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("upgraded version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ data, err := s.Get("user", "user-1")
+ if err != nil {
+ t.Fatalf("get preserved row: %v", err)
+ }
+ if string(data) != `{"id":"user-1","name":"alice"}` {
+ t.Fatalf("preserved row payload = %s, want original", data)
+ }
+}
+
+// TestSchemaVersion_FreshDBHasPlaybooksTable verifies that a fresh learn-
+// enabled database carries the learning_playbooks table after Open. The
+// v7 migration is the canonical home of the playbook primitive; opening a
+// new DB must leave it queryable without an upgrade step.
+func TestSchemaVersion_FreshDBHasPlaybooksTable(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open fresh db: %v", err)
+ }
+ defer s.Close()
+
+ var name string
+ err = s.DB().QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='learning_playbooks'`,
+ ).Scan(&name)
+ if err != nil {
+ t.Fatalf("learning_playbooks table should exist after Open on a fresh learn-enabled DB: %v", err)
+ }
+ if name != "learning_playbooks" {
+ t.Fatalf("expected learning_playbooks, got %q", name)
+ }
+
+ // Schema sanity: inserting a row exercises the NOT NULL + DEFAULT
+ // constraints in the migration. If the canonical column shape ever
+ // drifts (e.g., a NOT NULL added without a DEFAULT), this fails fast.
+ if _, err := s.DB().Exec(
+ `INSERT INTO learning_playbooks (query_family, playbook_json) VALUES (?, ?)`,
+ "odds_for_X", `{"slots":["X"]}`,
+ ); err != nil {
+ t.Fatalf("insert into learning_playbooks: %v", err)
+ }
+}
+
+// TestMigrate_V6ToV7AddsPlaybooks verifies that opening a v6-stamped
+// database upgrades to v7 by adding the learning_playbooks table without
+// touching any existing data. The v7 migration is purely additive; a
+// future refactor that turns it destructive would silently nuke every
+// existing library CLI's learn-loop state on first reopen.
+func TestMigrate_V6ToV7AddsPlaybooks(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ // Mirror the v6-stamped shape: resources composite key plus the
+ // learn-loop tables that existed at v6 (search_learnings,
+ // entity_lookups, search_patterns). Seed a row in each so we can
+ // prove the upgrade preserves them.
+ stmts := []string{
+ `CREATE TABLE resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`,
+ `INSERT INTO resources (id, resource_type, data) VALUES ('user-1', 'user', '{"id":"user-1","name":"alice"}')`,
+ `CREATE TABLE search_learnings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_pattern TEXT NOT NULL,
+ query_entities TEXT,
+ venue TEXT,
+ resource_type TEXT,
+ resource_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ alias_target TEXT,
+ source TEXT NOT NULL,
+ confidence INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ notes TEXT
+ )`,
+ `INSERT INTO search_learnings (query_pattern, resource_id, action, source) VALUES ('alice', 'user-1', 'boost', 'taught')`,
+ `PRAGMA user_version = 6`,
+ }
+ for _, stmt := range stmts {
+ if _, err := raw.Exec(stmt); err != nil {
+ raw.Close()
+ t.Fatalf("seed v6 db (%s): %v", stmt, err)
+ }
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("upgraded version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ // learning_playbooks must exist post-upgrade and be writable.
+ var name string
+ if err := s.DB().QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='learning_playbooks'`,
+ ).Scan(&name); err != nil {
+ t.Fatalf("learning_playbooks should exist after v6->v7 upgrade: %v", err)
+ }
+ if _, err := s.DB().Exec(
+ `INSERT INTO learning_playbooks (query_family, notes_text) VALUES (?, ?)`,
+ "family_X", "hand-authored notes",
+ ); err != nil {
+ t.Fatalf("insert into upgraded learning_playbooks: %v", err)
+ }
+
+ // Pre-existing data preserved: resources row, search_learnings row.
+ data, err := s.Get("user", "user-1")
+ if err != nil {
+ t.Fatalf("get preserved resources row: %v", err)
+ }
+ if string(data) != `{"id":"user-1","name":"alice"}` {
+ t.Fatalf("preserved row payload = %s, want original", data)
+ }
+ var learnedCount int
+ if err := s.DB().QueryRow(`SELECT COUNT(*) FROM search_learnings WHERE resource_id = 'user-1'`).Scan(&learnedCount); err != nil {
+ t.Fatalf("count preserved search_learnings: %v", err)
+ }
+ if learnedCount != 1 {
+ t.Fatalf("preserved search_learnings count = %d, want 1", learnedCount)
+ }
+}
+
+// TestMigrate_V6ToV7IsIdempotent verifies that running Open twice on a
+// learn-enabled DB does not double-migrate or trip a duplicate-table /
+// duplicate-index error. Every v7 migration statement uses IF NOT EXISTS,
+// so the second Open should reach the version stamp without an error and
+// leave learning_playbooks untouched.
+func TestMigrate_V6ToV7IsIdempotent(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ s1, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("first open: %v", err)
+ }
+ if _, err := s1.DB().Exec(
+ `INSERT INTO learning_playbooks (query_family, playbook_json) VALUES (?, ?)`,
+ "family_idem", `{"slots":["X"]}`,
+ ); err != nil {
+ s1.Close()
+ t.Fatalf("seed playbook row: %v", err)
+ }
+ s1.Close()
+
+ s2, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("second open: %v", err)
+ }
+ defer s2.Close()
+
+ v, err := s2.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version after re-open: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("re-opened version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ var count int
+ if err := s2.DB().QueryRow(`SELECT COUNT(*) FROM learning_playbooks WHERE query_family = 'family_idem'`).Scan(&count); err != nil {
+ t.Fatalf("count seeded playbook row: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("playbook row count after re-open = %d, want 1", count)
+ }
+}
+
+// requireTableExists fails unless name is a real table in the open store.
+func requireTableExists(t *testing.T, s *Store, name string) {
+ t.Helper()
+ var got string
+ if err := s.DB().QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name,
+ ).Scan(&got); err != nil {
+ t.Fatalf("table %s should exist: %v", name, err)
+ }
+}
+
+// TestSchemaVersion_FreshDBHasCandidateAndEventTables verifies a fresh
+// learn-enabled database opens at the current (v9) version with both new
+// tables queryable and their CHECK constraints enforced. Candidates are the
+// structural quarantine for CLI-derived observations; events are the
+// measurement substrate — neither may silently regress to a missing table
+// or an unchecked enum column.
+func TestSchemaVersion_FreshDBHasCandidateAndEventTables(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open fresh db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("fresh learn db version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ requireTableExists(t, s, "learn_candidates")
+ requireTableExists(t, s, "learn_events")
+
+ if _, err := s.DB().Exec(
+ `INSERT INTO learn_candidates (class, payload, derivation_signature, query_family, command_path, created_at, updated_at, last_seen_at)
+ VALUES ('flag_alias', '{"from":"--fmt","to":"--format"}', 'sig-1', 'family_X', 'items list', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
+ ); err != nil {
+ t.Fatalf("insert valid candidate: %v", err)
+ }
+ if _, err := s.DB().Exec(
+ `INSERT INTO learn_candidates (class, payload, derivation_signature, created_at, updated_at, last_seen_at)
+ VALUES ('bogus_class', '{}', 'sig-2', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
+ ); err == nil {
+ t.Fatalf("candidate class CHECK should reject an unknown class")
+ }
+
+ if _, err := s.DB().Exec(
+ `INSERT INTO learn_events (ts, event, query_family_hash, matched_row_id, entity_match, surface)
+ VALUES ('2026-01-01T00:00:00Z', 'recall_hit', 'hash-1', 1, 1, 'cli')`,
+ ); err != nil {
+ t.Fatalf("insert valid event: %v", err)
+ }
+ if _, err := s.DB().Exec(
+ `INSERT INTO learn_events (ts, event) VALUES ('2026-01-01T00:00:00Z', 'bogus_event')`,
+ ); err == nil {
+ t.Fatalf("event CHECK should reject an unknown event name")
+ }
+}
+
+// TestMigrate_V4ToV9AdditiveNoFTSContentRewrite verifies the FTS decouple:
+// a v4-stamped store opened by the v9 binary takes the additive-only path.
+// The learn tables are created, the version advances, and the FTS content
+// rewrite does NOT run — resourcesFTSContentSchemaVersion is pinned at 4,
+// so a store stamped at 4 already carries extracted-leaf content and a
+// sentinel FTS row must survive the open byte-for-byte at its rowid.
+func TestMigrate_V4ToV9AdditiveNoFTSContentRewrite(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v4 resources: %v", err)
+ }
+ if _, err := raw.Exec(resourcesFTSCreateSQL); err != nil {
+ raw.Close()
+ t.Fatalf("create v4 resources_fts: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('user-1', 'user', '{"id":"user-1","name":"alice"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("seed v4 resource: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (?, 'user-1', 'user', 'sentinel fts')`, ftsRowID("user", "user-1")); err != nil {
+ raw.Close()
+ t.Fatalf("seed v4 resources_fts row: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 4`); err != nil {
+ raw.Close()
+ t.Fatalf("stamp v4: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open v4 db with v9 binary: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("upgraded version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ requireTableExists(t, s, "learn_candidates")
+ requireTableExists(t, s, "learn_events")
+
+ // The rewrite gate must not have fired: the sentinel content is still
+ // exactly what the v4 binary wrote, at the same content-addressed rowid.
+ var content string
+ var rowid int64
+ if err := s.DB().QueryRow(`SELECT rowid, content FROM resources_fts WHERE id = 'user-1' AND resource_type = 'user'`).Scan(&rowid, &content); err != nil {
+ t.Fatalf("read resources_fts after v4 open: %v", err)
+ }
+ if content != "sentinel fts" {
+ t.Fatalf("resources_fts content = %q; the v4->v9 open must not rewrite FTS content", content)
+ }
+ if want := ftsRowID("user", "user-1"); rowid != want {
+ t.Fatalf("resources_fts rowid = %d, want preserved %d", rowid, want)
+ }
+
+ data, err := s.Get("user", "user-1")
+ if err != nil {
+ t.Fatalf("get preserved v4 row: %v", err)
+ }
+ if string(data) != `{"id":"user-1","name":"alice"}` {
+ t.Fatalf("preserved v4 row payload = %s, want original", data)
+ }
+}
+
+// TestMigrate_V8ToV9AddsCandidatesAndEvents verifies the v8->v9 upgrade is
+// purely additive: a v8-stamped store (learn tables through
+// learning_playbooks) gains learn_candidates and learn_events, keeps every
+// learn row intact, and never touches FTS content.
+func TestMigrate_V8ToV9AddsCandidatesAndEvents(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ stmts := []string{
+ `CREATE TABLE resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`,
+ `INSERT INTO resources (id, resource_type, data) VALUES ('user-1', 'user', '{"id":"user-1","name":"alice"}')`,
+ resourcesFTSCreateSQL,
+ `CREATE TABLE search_learnings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_pattern TEXT NOT NULL,
+ query_entities TEXT,
+ venue TEXT,
+ resource_type TEXT,
+ resource_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ alias_target TEXT,
+ source TEXT NOT NULL,
+ confidence INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ notes TEXT
+ )`,
+ `INSERT INTO search_learnings (query_pattern, resource_id, action, source) VALUES ('alice', 'user-1', 'boost', 'taught')`,
+ `CREATE TABLE learning_playbooks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_family TEXT NOT NULL UNIQUE,
+ playbook_json TEXT,
+ notes_text TEXT,
+ source TEXT NOT NULL DEFAULT 'taught',
+ confidence INTEGER NOT NULL DEFAULT 2,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at TIMESTAMP
+ )`,
+ `INSERT INTO learning_playbooks (query_family, notes_text) VALUES ('family_X', 'hand-authored notes')`,
+ `PRAGMA user_version = 8`,
+ }
+ for _, stmt := range stmts {
+ if _, err := raw.Exec(stmt); err != nil {
+ raw.Close()
+ t.Fatalf("seed v8 db (%s): %v", stmt, err)
+ }
+ }
+ if _, err := raw.Exec(`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (?, 'user-1', 'user', 'sentinel fts')`, ftsRowID("user", "user-1")); err != nil {
+ raw.Close()
+ t.Fatalf("seed v8 resources_fts row: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("upgraded version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ // New tables exist and accept rows post-upgrade.
+ requireTableExists(t, s, "learn_candidates")
+ requireTableExists(t, s, "learn_events")
+ if _, err := s.DB().Exec(
+ `INSERT INTO learn_candidates (class, payload, derivation_signature, created_at, updated_at, last_seen_at)
+ VALUES ('playbook_candidate', '{}', 'sig-up', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
+ ); err != nil {
+ t.Fatalf("insert into upgraded learn_candidates: %v", err)
+ }
+ if _, err := s.DB().Exec(
+ `INSERT INTO learn_events (ts, event, surface) VALUES ('2026-01-01T00:00:00Z', 'teach', 'mcp')`,
+ ); err != nil {
+ t.Fatalf("insert into upgraded learn_events: %v", err)
+ }
+
+ // Pre-existing learn data preserved.
+ var learnedCount int
+ if err := s.DB().QueryRow(`SELECT COUNT(*) FROM search_learnings WHERE resource_id = 'user-1'`).Scan(&learnedCount); err != nil {
+ t.Fatalf("count preserved search_learnings: %v", err)
+ }
+ if learnedCount != 1 {
+ t.Fatalf("preserved search_learnings count = %d, want 1", learnedCount)
+ }
+ var notes string
+ if err := s.DB().QueryRow(`SELECT notes_text FROM learning_playbooks WHERE query_family = 'family_X'`).Scan(¬es); err != nil {
+ t.Fatalf("read preserved playbook: %v", err)
+ }
+ if notes != "hand-authored notes" {
+ t.Fatalf("preserved playbook notes = %q, want original", notes)
+ }
+
+ // v8 is past the FTS content pin (4), so the rewrite must not run.
+ var content string
+ if err := s.DB().QueryRow(`SELECT content FROM resources_fts WHERE id = 'user-1' AND resource_type = 'user'`).Scan(&content); err != nil {
+ t.Fatalf("read resources_fts after v8 open: %v", err)
+ }
+ if content != "sentinel fts" {
+ t.Fatalf("resources_fts content = %q; the v8->v9 open must not rewrite FTS content", content)
+ }
+}
+
+// TestOpenReadOnly_RejectsWrites pins the contract: direct and CTE-wrapped
+// writes against the main DB fail under mode=ro. Deliberately does not
+// assert VACUUM INTO and ATTACH DATABASE — modernc.org/sqlite allows both
+// under mode=ro, so those defenses live in the handleSQL keyword blocklist.
+func TestOpenReadOnly_RejectsWrites(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ rw, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("seed open: %v", err)
+ }
+ if _, err := rw.DB().Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('seed', 'thing', '{}')`); err != nil {
+ t.Fatalf("seed insert: %v", err)
+ }
+ rw.Close()
+
+ ro, err := OpenReadOnly(dbPath)
+ if err != nil {
+ t.Fatalf("open read-only: %v", err)
+ }
+ defer ro.Close()
+
+ writes := []struct {
+ name string
+ stmt string
+ }{
+ {"insert", `INSERT INTO resources (id, resource_type, data) VALUES ('x', 'y', '{}')`},
+ {"update", `UPDATE resources SET resource_type = 'hijacked' WHERE id = 'seed'`},
+ {"delete", `DELETE FROM resources WHERE id = 'seed'`},
+ {"replace", `REPLACE INTO resources (id, resource_type, data) VALUES ('seed', 'evil', '{}')`},
+ // CTE-wrapped INSERT is load-bearing: it justifies leaving WITH
+ // out of the handleSQL blocklist so SELECT-form CTEs work.
+ {"cte_insert", `WITH stale AS (SELECT id FROM resources) INSERT INTO resources (id, resource_type, data) SELECT id || '-evil', 'thing', '{}' FROM stale`},
+ }
+ for _, w := range writes {
+ if _, err := ro.DB().Exec(w.stmt); err == nil {
+ t.Errorf("%s succeeded under mode=ro; expected rejection. stmt=%q", w.name, w.stmt)
+ }
+ }
+
+ var count int
+ if err := ro.DB().QueryRow(`SELECT COUNT(*) FROM resources`).Scan(&count); err != nil {
+ t.Fatalf("read-only SELECT failed: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("SELECT returned %d rows, want 1 (only the seed should remain)", count)
+ }
+ if err := ro.DB().QueryRow(`WITH r AS (SELECT id FROM resources WHERE id = 'seed') SELECT COUNT(*) FROM r`).Scan(&count); err != nil {
+ t.Fatalf("read-only WITH...SELECT CTE failed: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("CTE SELECT returned %d rows, want 1", count)
+ }
+}
+
+// TestMigrate_AddsColumnsOnUpgrade_Account verifies that opening a
+// database created by an older binary succeeds and adds newly generated
+// columns before CREATE INDEX runs against the pre-existing table. Regression
+// coverage for parent_id upgrades and indexed generated columns.
+func TestMigrate_AddsColumnsOnUpgrade_Account(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Pre-create the DB with the older table shape: id, data, synced_at and
+ // none of the newer generated columns. user_version stays 0 (pre-gate).
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE "account" (
+ id TEXT PRIMARY KEY,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create old table: %v", err)
+ }
+ raw.Close()
+
+ // Opening with the new binary must run CREATE INDEX statements without
+ // erroring on missing generated columns.
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ // The migration must have added every generated column.
+ rows, err := s.DB().Query(`PRAGMA table_info("account")`)
+ if err != nil {
+ t.Fatalf("table_info: %v", err)
+ }
+ defer rows.Close()
+
+ hasColumn := make(map[string]bool)
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pk int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ hasColumn[name] = true
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("rows: %v", err)
+ }
+
+ for _, want := range []string{
+ "first_name",
+ "last_name",
+ "email",
+ "metro_id",
+ } {
+ if !hasColumn[want] {
+ t.Fatalf("%s column missing from account after migrate", want)
+ }
+ }
+}
+
+// TestMigrate_AddsColumnsOnUpgrade_Categories verifies that opening a
+// database created by an older binary succeeds and adds newly generated
+// columns before CREATE INDEX runs against the pre-existing table. Regression
+// coverage for parent_id upgrades and indexed generated columns.
+func TestMigrate_AddsColumnsOnUpgrade_Categories(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Pre-create the DB with the older table shape: id, data, synced_at and
+ // none of the newer generated columns. user_version stays 0 (pre-gate).
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE "categories" (
+ id TEXT PRIMARY KEY,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create old table: %v", err)
+ }
+ raw.Close()
+
+ // Opening with the new binary must run CREATE INDEX statements without
+ // erroring on missing generated columns.
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ // The migration must have added every generated column.
+ rows, err := s.DB().Query(`PRAGMA table_info("categories")`)
+ if err != nil {
+ t.Fatalf("table_info: %v", err)
+ }
+ defer rows.Close()
+
+ hasColumn := make(map[string]bool)
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pk int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ hasColumn[name] = true
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("rows: %v", err)
+ }
+
+ for _, want := range []string{
+ "title",
+ "category_name",
+ "category_id",
+ "default_template_id",
+ "top_category",
+ "fallback",
+ } {
+ if !hasColumn[want] {
+ t.Fatalf("%s column missing from categories after migrate", want)
+ }
+ }
+}
+
+// TestMigrate_AddsColumnsOnUpgrade_System verifies that opening a
+// database created by an older binary succeeds and adds newly generated
+// columns before CREATE INDEX runs against the pre-existing table. Regression
+// coverage for parent_id upgrades and indexed generated columns.
+func TestMigrate_AddsColumnsOnUpgrade_System(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Pre-create the DB with the older table shape: id, data, synced_at and
+ // none of the newer generated columns. user_version stays 0 (pre-gate).
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE "system" (
+ id TEXT PRIMARY KEY,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create old table: %v", err)
+ }
+ raw.Close()
+
+ // Opening with the new binary must run CREATE INDEX statements without
+ // erroring on missing generated columns.
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ // The migration must have added every generated column.
+ rows, err := s.DB().Query(`PRAGMA table_info("system")`)
+ if err != nil {
+ t.Fatalf("table_info: %v", err)
+ }
+ defer rows.Close()
+
+ hasColumn := make(map[string]bool)
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pk int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ hasColumn[name] = true
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("rows: %v", err)
+ }
+
+ for _, want := range []string{
+ "stream_api_key",
+ "support_key",
+ } {
+ if !hasColumn[want] {
+ t.Fatalf("%s column missing from system after migrate", want)
+ }
+ }
+}
+
+// TestMigrate_AddsColumnsOnUpgrade_Taskers verifies that opening a
+// database created by an older binary succeeds and adds newly generated
+// columns before CREATE INDEX runs against the pre-existing table. Regression
+// coverage for parent_id upgrades and indexed generated columns.
+func TestMigrate_AddsColumnsOnUpgrade_Taskers(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Pre-create the DB with the older table shape: id, data, synced_at and
+ // none of the newer generated columns. user_version stays 0 (pre-gate).
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE "taskers" (
+ id TEXT PRIMARY KEY,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create old table: %v", err)
+ }
+ raw.Close()
+
+ // Opening with the new binary must run CREATE INDEX statements without
+ // erroring on missing generated columns.
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ // The migration must have added every generated column.
+ rows, err := s.DB().Query(`PRAGMA table_info("taskers")`)
+ if err != nil {
+ t.Fatalf("table_info: %v", err)
+ }
+ defer rows.Close()
+
+ hasColumn := make(map[string]bool)
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pk int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ hasColumn[name] = true
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("rows: %v", err)
+ }
+
+ for _, want := range []string{
+ "name",
+ "hourly_rate",
+ "rating",
+ "review_count",
+ "tasks_completed",
+ "elite",
+ "metro_id",
+ } {
+ if !hasColumn[want] {
+ t.Fatalf("%s column missing from taskers after migrate", want)
+ }
+ }
+}
+
+// TestMigrate_AddsColumnsOnUpgrade_SyncState verifies that opening a
+// database created by an older binary succeeds and adds newly generated
+// columns before CREATE INDEX runs against the pre-existing table. Regression
+// coverage for parent_id upgrades and indexed generated columns.
+func TestMigrate_AddsColumnsOnUpgrade_SyncState(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Pre-create the DB with the older table shape: id, data, synced_at and
+ // none of the newer generated columns. user_version stays 0 (pre-gate).
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE "sync_state" (
+ id TEXT PRIMARY KEY,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create old table: %v", err)
+ }
+ raw.Close()
+
+ // Opening with the new binary must run CREATE INDEX statements without
+ // erroring on missing generated columns.
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ // The migration must have added every generated column.
+ rows, err := s.DB().Query(`PRAGMA table_info("sync_state")`)
+ if err != nil {
+ t.Fatalf("table_info: %v", err)
+ }
+ defer rows.Close()
+
+ hasColumn := make(map[string]bool)
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pk int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ hasColumn[name] = true
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("rows: %v", err)
+ }
+
+ for _, want := range []string{
+ "last_cursor",
+ "last_synced_at",
+ "total_count",
+ } {
+ if !hasColumn[want] {
+ t.Fatalf("%s column missing from sync_state after migrate", want)
+ }
+ }
+}
diff --git a/library/productivity/human-goat/internal/store/store.go b/library/productivity/human-goat/internal/store/store.go
new file mode 100644
index 0000000000..7e35bbad31
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/store.go
@@ -0,0 +1,2408 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// Package store provides local SQLite persistence for human-goat-pp-cli.
+// Uses modernc.org/sqlite (pure Go, no CGO) for zero-dependency cross-compilation.
+// FTS5 full-text search indexes are created for searchable content.
+package store
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "hash/fnv"
+ "math"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+var isoDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+-Zz]+)?$`)
+var ftsQueryTokenRE = regexp.MustCompile(`[\pL\pN_]+`)
+
+var sqliteDriverInit struct {
+ mu sync.Mutex
+ done bool
+}
+
+// validIdentifierRE pins ListField's `field` argument to a safe SQL
+// identifier shape before any Sprintf interpolation. Matches what
+// pragma_table_info implicitly enforces on the primary path, so the
+// fallback path inherits the same defense without depending on whether
+// the parent's typed domain table exists at the moment of the lookup.
+var validIdentifierRE = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
+
+// IsUUID returns true if the input looks like a UUID.
+func IsUUID(s string) bool {
+ return uuidPattern.MatchString(s)
+}
+
+// StoreSchemaVersion is the on-disk schema version this binary understands.
+// It is stamped into SQLite's PRAGMA user_version on fresh databases and
+// checked on every open. Learn-enabled CLIs advance to v9 for the
+// learn_candidates and learn_events tables (CLI-side capture and
+// measurement), on top of the v8 learning_playbooks table for
+// hand-authored choreography keyed by query family and the v6 canonical
+// learn-loop tables ported from prediction-goat (including the v3
+// resources_fts rowid rehash and v4 resources_fts content extraction).
+const StoreSchemaVersion = 9
+
+// resourcesFTSContentSchemaVersion pins the schema bump that rewrote
+// resources_fts content from raw JSON to searchable leaf values. Keep this
+// separate from StoreSchemaVersion — and pinned at 4 regardless of the
+// learn shape — so schema bumps that only add tables (the learn
+// migrations) never trigger an expensive full FTS content rewrite. A
+// store stamped at v4 or later already carries the extracted-leaf FTS
+// content; opening it with a newer binary must stay additive-only.
+const resourcesFTSContentSchemaVersion = 4
+
+const resourcesFTSCreateSQL = `CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
+ id, resource_type, content, tokenize='porter unicode61'
+)`
+
+type Store struct {
+ db *sql.DB
+ // writeMu serializes all DB writes. Read paths bypass the lock and run
+ // concurrently against WAL. Resource-level concurrency in sync.go.tmpl
+ // is 1 (one goroutine per resource via len(resources)-sized work channel)
+ // — read-then-write sequences (e.g., GetSyncCursor → SaveSyncState) are
+ // race-free by construction within a resource.
+ writeMu sync.Mutex
+ path string
+}
+
+// Open opens or creates the SQLite store at dbPath using the background
+// context. Prefer OpenWithContext from a Cobra command so SIGINT during
+// a slow migration interrupts the open instead of stranding the caller.
+func Open(dbPath string) (*Store, error) {
+ return OpenWithContext(context.Background(), dbPath)
+}
+
+// OpenReadOnly opens an existing SQLite store at dbPath in read-only mode.
+// mode=ro rejects direct and CTE-wrapped writes (INSERT, UPDATE, DELETE,
+// REPLACE, "WITH x AS (...) INSERT ...") at the driver level. Skips
+// MkdirAll and migrate; the file is expected to exist.
+//
+// The file: URI prefix is load-bearing: modernc.org/sqlite only honors
+// SQLite's URI query parameters (mode, cache, etc.) when the DSN starts
+// with "file:". Without the prefix, "?mode=ro" is silently dropped and
+// the connection opens read-write. Pragmas use the driver's _pragma=
+// name(value) syntax — modernc.org/sqlite does NOT recognize the
+// mattn/go-sqlite3 _journal_mode=WAL / _busy_timeout=5000 form and drops
+// those keys silently, so the busy_timeout below is what keeps a read
+// concurrent with a writer from failing immediately with SQLITE_BUSY.
+//
+// Deliberately no journal_mode pragma here: journal mode is a property of
+// the database file, set by the read-write open, not the connection. Issuing
+// PRAGMA journal_mode=WAL on a read-only handle to a DB still in the default
+// delete mode (e.g. a pre-WAL database opened by an old binary before its
+// first read-write open) errors with "attempt to write a readonly database".
+//
+// OpenReadOnly uses context.Background(); callers holding a context should use
+// OpenReadOnlyContext so a cancelled command (SIGINT, deadline) interrupts the
+// SQLITE_BUSY retry during driver init instead of waiting out the full timeout.
+func OpenReadOnly(dbPath string) (*Store, error) {
+ return OpenReadOnlyContext(context.Background(), dbPath)
+}
+
+// OpenReadOnlyContext is OpenReadOnly with a caller-supplied context honored by
+// the driver-init SQLITE_BUSY retry.
+func OpenReadOnlyContext(ctx context.Context, dbPath string) (*Store, error) {
+ dsn := "file:" + dbPath + "?mode=ro&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)"
+ if err := ensureSQLiteDriverInitialized(ctx, dsn); err != nil {
+ return nil, err
+ }
+
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("opening database (read-only): %w", err)
+ }
+ db.SetMaxOpenConns(2)
+ return &Store{db: db, path: dbPath}, nil
+}
+
+// OpenWithContext opens or creates the SQLite store at dbPath. The
+// context is honored by the migration path: cancellation interrupts the
+// retry-on-SQLITE_BUSY loop and propagates ctx.Err() back to the caller
+// instead of waiting out the full migrationLockTimeout.
+func OpenWithContext(ctx context.Context, dbPath string) (*Store, error) {
+ if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil {
+ return nil, fmt.Errorf("creating db directory: %w", err)
+ }
+
+ // Pragma order is load-bearing: busy_timeout must engage BEFORE
+ // journal_mode(WAL) so the delete→WAL conversion (an exclusive
+ // operation on a fresh DB) runs with a busy handler active. With the
+ // timeout listed after the conversion, concurrent first-run opens
+ // race the WAL switch and fail SQLITE_BUSY instead of waiting. This
+ // mirrors the OpenReadOnly DSN and works alongside the retryOnBusy
+ // wrapper around Conn() acquisition below; both layers are needed
+ // because modernc.org/sqlite's connect-time conversion is not fully
+ // covered by the statement-level busy handler alone.
+ dsn := dbPath + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)"
+ if err := ensureSQLiteDriverInitialized(ctx, dsn); err != nil {
+ return nil, err
+ }
+
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("opening database: %w", err)
+ }
+
+ // WAL mode + 2 connections allows one read cursor open while a second
+ // query executes (e.g., analytics commands calling helpers during row
+ // iteration). Writes are still serialized by SQLite's WAL lock.
+ db.SetMaxOpenConns(2)
+
+ s := &Store{db: db, path: dbPath}
+ if err := s.migrate(ctx); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("running migrations: %w", err)
+ }
+
+ return s, nil
+}
+
+func ensureSQLiteDriverInitialized(ctx context.Context, dsn string) error {
+ sqliteDriverInit.mu.Lock()
+ defer sqliteDriverInit.mu.Unlock()
+
+ if sqliteDriverInit.done {
+ return nil
+ }
+
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return fmt.Errorf("opening database for driver initialization: %w", err)
+ }
+ defer db.Close()
+
+ // Acquiring the first physical connection runs the DSN _pragma directives,
+ // including the journal_mode(WAL) conversion for a read-write DSN. On a
+ // fresh DB opened concurrently — e.g. the scorecard live-check probing
+ // sampled commands in parallel — that conversion can return SQLITE_BUSY
+ // before the DSN's busy_timeout engages, so retry the acquisition against a
+ // bounded deadline. SQLITE_BUSY here is always transient.
+ deadline := time.Now().Add(migrationLockTimeout)
+ var conn *sql.Conn
+ if err := retryOnBusy(ctx, deadline, "initializing sqlite driver", func() error {
+ c, err := db.Conn(ctx)
+ if err != nil {
+ return err
+ }
+ conn = c
+ return nil
+ }); err != nil {
+ return err
+ }
+ if err := conn.Close(); err != nil {
+ return fmt.Errorf("closing sqlite initialization connection: %w", err)
+ }
+
+ sqliteDriverInit.done = true
+ return nil
+}
+
+func (s *Store) Close() error {
+ return s.db.Close()
+}
+
+// Path returns the on-disk path of the backing SQLite file.
+func (s *Store) Path() string {
+ return s.path
+}
+
+// DB exposes the underlying *sql.DB for callers that need to run ad-hoc
+// queries (e.g., doctor's cache inspection, share snapshot import).
+// Callers must not call Close on the returned handle.
+func (s *Store) DB() *sql.DB {
+ return s.db
+}
+
+// SchemaVersion reads PRAGMA user_version, which is stamped by migrate().
+// A zero value means the database predates the schema-version gate — not
+// a bug, but the caller may want to warn.
+func (s *Store) SchemaVersion() (int, error) {
+ var v int
+ if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
+ return 0, fmt.Errorf("read user_version: %w", err)
+ }
+ return v, nil
+}
+
+// ensureColumn adds a column to an existing table if it isn't already
+// present. It is the upgrade-path safety valve for schema additions:
+// CREATE TABLE IF NOT EXISTS is a no-op when the table already exists, so
+// columns added by newer binaries (e.g. parent_id from the dependent-
+// resources work) never land on databases created by older binaries —
+// which then trip "no such column" when a follow-on CREATE INDEX runs.
+//
+// Skips silently if the table doesn't yet exist (fresh install — the
+// CREATE TABLE migration will create it with the column already declared)
+// or if the column already exists. Runs on the pinned migration
+// connection so it sees the writes performed by the in-flight BEGIN
+// IMMEDIATE transaction; using s.db here would route through the pool
+// and BUSY against the holding writer under concurrent migrators.
+func (s *Store) ensureColumn(ctx context.Context, conn *sql.Conn, table, column, decl string) error {
+ var name string
+ err := conn.QueryRowContext(ctx,
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,
+ ).Scan(&name)
+ if err == sql.ErrNoRows {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("checking table %s: %w", table, err)
+ }
+
+ rows, err := conn.QueryContext(ctx, fmt.Sprintf(`PRAGMA table_info("%s")`, table))
+ if err != nil {
+ return fmt.Errorf("table_info %s: %w", table, err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var cid int
+ var n, typ string
+ var notnull, pk int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &n, &typ, ¬null, &dflt, &pk); err != nil {
+ return fmt.Errorf("scan table_info %s: %w", table, err)
+ }
+ if n == column {
+ return nil
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("iterating table_info %s: %w", table, err)
+ }
+
+ if _, err := conn.ExecContext(ctx, fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN "%s" %s`, table, column, decl)); err != nil {
+ // A concurrent Open() may have added the column between our
+ // PRAGMA check and this ALTER. SQLite returns SQLITE_ERROR with
+ // "duplicate column name", which busy_timeout does not retry.
+ // The DB is now in the desired state regardless of who won.
+ if strings.Contains(err.Error(), "duplicate column name") {
+ return nil
+ }
+ return fmt.Errorf("add column %s.%s: %w", table, column, err)
+ }
+ return nil
+}
+
+// backfillColumns adds columns that newer binaries declare but that
+// pre-existing databases (created before those columns were added) lack.
+// Must run before the migrations slice so that subsequent CREATE INDEX
+// statements referencing the column can succeed against the upgraded
+// table. Idempotent: safe to call on fresh DBs (table-not-found short-
+// circuit) and on already-current DBs (column-exists short-circuit).
+//
+// Table names are emitted bare (no safeName) — ensureColumn double-quotes
+// them at SQL emit time and uses parameter binding for the sqlite_master
+// lookup, so the values flow as Go string literals first and SQL
+// identifiers second. Wrapping with safeName here would embed literal
+// double-quote characters into the Go string and break compilation for
+// any spec whose dependent-resource snake_cased name is a SQL reserved
+// word.
+func (s *Store) backfillColumns(ctx context.Context, conn *sql.Conn) error {
+ for _, c := range []struct{ table, column, decl string }{
+ {table: "account", column: "first_name", decl: "TEXT"},
+ {table: "account", column: "last_name", decl: "TEXT"},
+ {table: "account", column: "email", decl: "TEXT"},
+ {table: "account", column: "metro_id", decl: "INTEGER"},
+ {table: "categories", column: "title", decl: "TEXT"},
+ {table: "categories", column: "category_name", decl: "TEXT"},
+ {table: "categories", column: "category_id", decl: "INTEGER"},
+ {table: "categories", column: "default_template_id", decl: "INTEGER"},
+ {table: "categories", column: "top_category", decl: "INTEGER"},
+ {table: "categories", column: "fallback", decl: "INTEGER"},
+ {table: "system", column: "stream_api_key", decl: "TEXT"},
+ {table: "system", column: "support_key", decl: "TEXT"},
+ {table: "taskers", column: "name", decl: "TEXT"},
+ {table: "taskers", column: "hourly_rate", decl: "REAL"},
+ {table: "taskers", column: "rating", decl: "REAL"},
+ {table: "taskers", column: "review_count", decl: "INTEGER"},
+ {table: "taskers", column: "tasks_completed", decl: "INTEGER"},
+ {table: "taskers", column: "elite", decl: "INTEGER"},
+ {table: "taskers", column: "metro_id", decl: "INTEGER"},
+ {table: "sync_state", column: "last_cursor", decl: "TEXT"},
+ {table: "sync_state", column: "last_synced_at", decl: "DATETIME"},
+ {table: "sync_state", column: "total_count", decl: "INTEGER DEFAULT 0"},
+ } {
+ if err := s.ensureColumn(ctx, conn, c.table, c.column, c.decl); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Store) migrate(ctx context.Context) error {
+ // Acquiring the migration connection establishes a physical SQLite
+ // connection, which runs the DSN _pragma directives — including the
+ // journal_mode(WAL) conversion. On a fresh DB opened by several
+ // processes at once, that conversion briefly needs an exclusive lock
+ // and can return SQLITE_BUSY before any statement-level busy handler
+ // applies, so retry the acquisition against the shared deadline.
+ deadline := time.Now().Add(migrationLockTimeout)
+ var conn *sql.Conn
+ if err := retryOnBusy(ctx, deadline, "acquiring migration connection", func() error {
+ c, err := s.db.Conn(ctx)
+ if err != nil {
+ return err
+ }
+ conn = c
+ return nil
+ }); err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // Read user_version before the migration lock so an old binary
+ // opening a newer-schema DB rejects immediately. WAL readers don't
+ // normally block on writers, but the fresh-DB WAL-init race can BUSY
+ // a SELECT — share the lock's deadline so total budget stays bounded.
+ var current int
+ if err := retryOnBusy(ctx, deadline, "reading schema version", func() error {
+ return conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(¤t)
+ }); err != nil {
+ return err
+ }
+ if current > StoreSchemaVersion {
+ return fmt.Errorf("database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database", current, StoreSchemaVersion)
+ }
+
+ migrations := []string{
+ `CREATE TABLE IF NOT EXISTS resources (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(resource_type)`,
+ `CREATE INDEX IF NOT EXISTS idx_resources_synced ON resources(synced_at)`,
+ `CREATE TABLE IF NOT EXISTS sync_state (
+ resource_type TEXT PRIMARY KEY,
+ last_cursor TEXT,
+ last_synced_at DATETIME,
+ total_count INTEGER DEFAULT 0
+ )`,
+ resourcesFTSCreateSQL,
+ // CLI Printing Press: learn migrations
+ //
+ // search_learnings: LLM-driven per-query reranking. Populated by
+ // the `teach` command (silent, backgrounded by the LLM after a
+ // successful response) and read by the rerank layer to
+ // boost/hide/alias hits on subsequent queries. See learnings.go
+ // for the full semantics. Per-user table; stays small.
+ //
+ // query_entities: JSON array of case-preserving entity tokens
+ // extracted from query_pattern at teach time. Used by the recall
+ // match validator to reject cross-entity matches that would
+ // otherwise score high on non-entity Jaccard.
+ `CREATE TABLE IF NOT EXISTS search_learnings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_pattern TEXT NOT NULL,
+ query_entities TEXT,
+ venue TEXT,
+ resource_type TEXT,
+ resource_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ alias_target TEXT,
+ source TEXT NOT NULL,
+ confidence INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ notes TEXT
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_learn_query ON search_learnings(query_pattern)`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_learn_unique ON search_learnings(query_pattern, resource_id, action)`,
+ // entity_lookups: canonical-to-value reference data for the
+ // pattern substitution engine in internal/learn/patterns. Seeded
+ // at migration time by the consumer (e.g., a CLI may register
+ // country codes, sports team abbreviations, etc.); per-user
+ // additions land via the `teach-lookup` CLI command with
+ // source='taught'. PK is the (kind, canonical, value) triple so
+ // multiple aliases under the same kind coexist without
+ // collision.
+ `CREATE TABLE IF NOT EXISTS entity_lookups (
+ kind TEXT NOT NULL,
+ canonical TEXT NOT NULL,
+ value TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'seeded',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (kind, canonical, value)
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_entity_lookup_canonical ON entity_lookups(canonical)`,
+ `CREATE INDEX IF NOT EXISTS idx_entity_lookup_kind ON entity_lookups(kind)`,
+ // search_patterns: inferred and taught templates for the
+ // generalization layer in internal/learn/patterns. Each row
+ // encodes a query_template with one {entity[:kind]} slot and a
+ // resource_template that names how the entity substitutes into
+ // the resource ID. Extract() writes "inferred" rows whenever
+ // two or more search_learnings rows share a structural shape;
+ // the teach-pattern CLI command writes "taught" rows directly
+ // for explicit template authorship.
+ //
+ // Idempotency leans on idx_patterns_unique: a re-Extract pass
+ // over the same source learnings re-asserts the same
+ // (query_template, resource_template, strategy) triple, which
+ // bumps confidence and refreshes last_observed_at on the
+ // existing row rather than spawning a duplicate.
+ `CREATE TABLE IF NOT EXISTS search_patterns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_template TEXT NOT NULL,
+ resource_template TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ venue TEXT,
+ strategy TEXT NOT NULL,
+ entity_kind TEXT NOT NULL,
+ confidence INTEGER NOT NULL DEFAULT 2,
+ source TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at DATETIME,
+ example_query TEXT,
+ example_resource TEXT
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_patterns_query_template ON search_patterns(query_template)`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_patterns_unique ON search_patterns(query_template, resource_template, strategy)`,
+ // learning_playbooks (v7): hand-authored playbook primitive
+ // keyed on the structural query family (all entities stripped;
+ // see learn.QueryFamily). One row per family holds the optional
+ // structured playbook (ordered CLI command sequence with entity
+ // slots) and the optional free-text notes (gotchas, workarounds
+ // the CLI surface doesn't expose). Either field may be empty;
+ // non-empty in both is the strongest signal.
+ //
+ // Read at recall time by query_family; surfaces to the agent
+ // alongside the existing per-resource hits so a future inquiry
+ // of the same shape can skip rediscovery of the choreography.
+ //
+ // Distinct concept from search_patterns (which auto-extracts
+ // generalization templates from search_learnings); playbooks
+ // are hand-authored choreography + notes attached by family.
+ `CREATE TABLE IF NOT EXISTS learning_playbooks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ query_family TEXT NOT NULL UNIQUE,
+ playbook_json TEXT,
+ notes_text TEXT,
+ source TEXT NOT NULL DEFAULT 'taught',
+ confidence INTEGER NOT NULL DEFAULT 2,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_observed_at TIMESTAMP
+ )`,
+ // query_family already carries a column-level UNIQUE constraint
+ // (SQLite auto-creates the backing index), so no separate
+ // CREATE UNIQUE INDEX is needed -- a second named unique index
+ // would just double the write cost on every upsert.
+ `CREATE INDEX IF NOT EXISTS idx_playbooks_source ON learning_playbooks(source)`,
+ `CREATE INDEX IF NOT EXISTS idx_playbooks_last_observed_at ON learning_playbooks(last_observed_at)`,
+ // learn_candidates (v9): CLI-derived improvement candidates
+ // awaiting explicit agent judgment. Rows are written by the
+ // post-run derivation pass (flag corrections, repeated
+ // discovery shapes) and surfaced read-only in the recall
+ // envelope. Candidates are structurally quarantined: they
+ // never become search_learnings rows and sightings never
+ // grant skip authority — only an explicit confirm promotes
+ // the payload. derivation_signature dedupes re-derivations of
+ // the same observation into a sightings bump instead of a
+ // duplicate row.
+ `CREATE TABLE IF NOT EXISTS learn_candidates (
+ id INTEGER PRIMARY KEY,
+ class TEXT NOT NULL CHECK(class IN ('flag_alias','playbook_candidate')),
+ payload TEXT NOT NULL,
+ derivation_signature TEXT NOT NULL UNIQUE,
+ sightings INTEGER NOT NULL DEFAULT 1,
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','confirmed','rejected','expired')),
+ query_family TEXT,
+ command_path TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_learn_candidates_status ON learn_candidates(status)`,
+ `CREATE INDEX IF NOT EXISTS idx_learn_candidates_family ON learn_candidates(query_family)`,
+ // learn_events (v9): capped, best-effort telemetry for the
+ // learn loop's measurement layer. recall logs hit/miss with
+ // the matched row id so teach-to-reuse joins by row id (family
+ // hash as fallback); `learnings stats` aggregates over it.
+ // Inserts are telemetry-class — they never fail the command
+ // and never hold writeMu across a recall match.
+ `CREATE TABLE IF NOT EXISTS learn_events (
+ id INTEGER PRIMARY KEY,
+ ts TEXT NOT NULL,
+ event TEXT NOT NULL CHECK(event IN ('recall_hit','recall_miss','recall_playbook_hit','teach','teach_playbook','amend','forget','candidate_confirmed','candidate_rejected')),
+ query_family_hash TEXT,
+ matched_row_id INTEGER,
+ entity_match INTEGER,
+ surface TEXT CHECK(surface IN ('cli','mcp'))
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_learn_events_event_ts ON learn_events(event, ts)`,
+ `CREATE TABLE IF NOT EXISTS "account" (
+ "id" TEXT PRIMARY KEY,
+ "data" JSON NOT NULL,
+ "synced_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
+ "first_name" TEXT,
+ "last_name" TEXT,
+ "email" TEXT,
+ "metro_id" INTEGER
+ )`,
+ `CREATE INDEX IF NOT EXISTS "idx_account_metro_id" ON "account"("metro_id")`,
+ `CREATE TABLE IF NOT EXISTS "categories" (
+ "id" TEXT PRIMARY KEY,
+ "data" JSON NOT NULL,
+ "synced_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
+ "title" TEXT,
+ "category_name" TEXT,
+ "category_id" INTEGER,
+ "default_template_id" INTEGER,
+ "top_category" INTEGER,
+ "fallback" INTEGER
+ )`,
+ `CREATE INDEX IF NOT EXISTS "idx_categories_category_id" ON "categories"("category_id")`,
+ `CREATE INDEX IF NOT EXISTS "idx_categories_default_template_id" ON "categories"("default_template_id")`,
+ `CREATE TABLE IF NOT EXISTS "system" (
+ "id" TEXT PRIMARY KEY,
+ "data" JSON NOT NULL,
+ "synced_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
+ "stream_api_key" TEXT,
+ "support_key" TEXT
+ )`,
+ `CREATE TABLE IF NOT EXISTS "taskers" (
+ "id" TEXT PRIMARY KEY,
+ "data" JSON NOT NULL,
+ "synced_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
+ "name" TEXT,
+ "hourly_rate" REAL,
+ "rating" REAL,
+ "review_count" INTEGER,
+ "tasks_completed" INTEGER,
+ "elite" INTEGER,
+ "metro_id" INTEGER
+ )`,
+ `CREATE INDEX IF NOT EXISTS "idx_taskers_metro_id" ON "taskers"("metro_id")`,
+ }
+
+ // Run every migration — including the column backfill and the
+ // schema-version stamp — inside a single BEGIN IMMEDIATE transaction
+ // pinned to one connection. IMMEDIATE acquires SQLite's RESERVED lock
+ // at BEGIN time so concurrent migrators serialize on it instead of
+ // racing per-statement and tripping SQLITE_BUSY despite busy_timeout.
+ // modernc.org/sqlite's busy_timeout does not always cover write-write
+ // contention at BEGIN/COMMIT time, so we retry both explicitly on
+ // SQLITE_BUSY for up to migrationLockTimeout.
+ return withMigrationLock(ctx, conn, deadline, func() error {
+ // Re-read user_version inside the lock. This is load-bearing,
+ // not paranoid: between the pre-lock read above and our
+ // successful BEGIN IMMEDIATE, a newer-binary peer may have
+ // committed a higher version stamp. Without this re-read, an
+ // older binary (smaller StoreSchemaVersion) would proceed to
+ // stamp its own lower version at the end of the closure,
+ // silently downgrading user_version on a schema that's already
+ // at the newer level. Future maintainers: leave this read in.
+ var current int
+ if err := conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(¤t); err != nil {
+ return fmt.Errorf("reading schema version: %w", err)
+ }
+ if current > StoreSchemaVersion {
+ return fmt.Errorf("database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database", current, StoreSchemaVersion)
+ }
+
+ if current < 2 {
+ if err := s.migrateResourcesCompositeKey(ctx, conn); err != nil {
+ return fmt.Errorf("migrating resources composite key: %w", err)
+ }
+ }
+ if current == 2 {
+ if err := s.migrateResourcesFTSRowIDs(ctx, conn); err != nil {
+ return fmt.Errorf("migrating resources FTS rowids: %w", err)
+ }
+ }
+
+ if err := s.backfillColumns(ctx, conn); err != nil {
+ return fmt.Errorf("backfilling columns: %w", err)
+ }
+ for _, m := range migrations {
+ if _, err := conn.ExecContext(ctx, m); err != nil {
+ return fmt.Errorf("migration failed: %w", err)
+ }
+ }
+ if err := s.migrateExtras(ctx, conn); err != nil {
+ return fmt.Errorf("running extra migrations: %w", err)
+ }
+ if current < resourcesFTSContentSchemaVersion {
+ if err := s.migrateResourcesFTSContent(ctx, conn); err != nil {
+ return fmt.Errorf("migrating resources FTS content: %w", err)
+ }
+ }
+ // Stamp the schema version. On a fresh DB this writes the current
+ // StoreSchemaVersion; on an already-stamped DB this is a no-op
+ // write of the same value.
+ // An older DB with user_version = 0 and pre-existing tables gets
+ // stamped here after any version-gated rewrites and idempotent
+ // CREATE TABLE IF NOT EXISTS statements have completed.
+ if _, err := conn.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version = %d`, StoreSchemaVersion)); err != nil {
+ return fmt.Errorf("stamp user_version: %w", err)
+ }
+ return nil
+ })
+}
+
+func (s *Store) migrateResourcesCompositeKey(ctx context.Context, conn *sql.Conn) error {
+ exists, err := tableExists(ctx, conn, "resources")
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return nil
+ }
+
+ composite, err := resourcesTableHasCompositeKey(ctx, conn)
+ if err != nil {
+ return err
+ }
+ if !composite {
+ if _, err := conn.ExecContext(ctx, `CREATE TABLE resources_v2 (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`); err != nil {
+ return fmt.Errorf("creating resources_v2: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, `INSERT INTO resources_v2 (id, resource_type, data, synced_at, updated_at)
+ SELECT id, resource_type, data, synced_at, updated_at FROM resources`); err != nil {
+ return fmt.Errorf("copying resources rows: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, `DROP TABLE resources`); err != nil {
+ return fmt.Errorf("dropping old resources table: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, `ALTER TABLE resources_v2 RENAME TO resources`); err != nil {
+ return fmt.Errorf("renaming resources_v2: %w", err)
+ }
+ }
+
+ // Always rebuild FTS during the v2 transition. The resources table may
+ // already have the composite key, but v1 FTS rowids were scoped by id
+ // alone and must be replaced with resource_type + id rowids.
+ if _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS resources_fts`); err != nil {
+ return fmt.Errorf("dropping resources_fts: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, resourcesFTSCreateSQL); err != nil {
+ return fmt.Errorf("creating resources_fts: %w", err)
+ }
+ if err := rebuildResourcesFTS(ctx, conn); err != nil {
+ return fmt.Errorf("rebuilding resources_fts: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) migrateResourcesFTSRowIDs(ctx context.Context, conn *sql.Conn) error {
+ exists, err := tableExists(ctx, conn, "resources")
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return nil
+ }
+
+ if _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS resources_fts`); err != nil {
+ return fmt.Errorf("dropping resources_fts: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, resourcesFTSCreateSQL); err != nil {
+ return fmt.Errorf("creating resources_fts: %w", err)
+ }
+ if err := rebuildResourcesFTS(ctx, conn); err != nil {
+ return fmt.Errorf("rebuilding resources_fts: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) migrateResourcesFTSContent(ctx context.Context, conn *sql.Conn) error {
+ exists, err := tableExists(ctx, conn, "resources")
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return nil
+ }
+
+ if _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS resources_fts`); err != nil {
+ return fmt.Errorf("dropping resources_fts: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, resourcesFTSCreateSQL); err != nil {
+ return fmt.Errorf("creating resources_fts: %w", err)
+ }
+ if err := rebuildResourcesFTS(ctx, conn); err != nil {
+ return fmt.Errorf("rebuilding resources_fts: %w", err)
+ }
+ return nil
+}
+
+func tableExists(ctx context.Context, conn *sql.Conn, name string) (bool, error) {
+ var count int
+ if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&count); err != nil {
+ return false, fmt.Errorf("checking table %s: %w", name, err)
+ }
+ return count > 0, nil
+}
+
+func resourcesTableHasCompositeKey(ctx context.Context, conn *sql.Conn) (bool, error) {
+ rows, err := conn.QueryContext(ctx, `PRAGMA table_info(resources)`)
+ if err != nil {
+ return false, fmt.Errorf("reading resources table info: %w", err)
+ }
+ defer rows.Close()
+
+ pk := map[string]int{}
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pkOrder int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pkOrder); err != nil {
+ return false, fmt.Errorf("scanning resources table info: %w", err)
+ }
+ pk[name] = pkOrder
+ }
+ if err := rows.Err(); err != nil {
+ return false, fmt.Errorf("reading resources table info rows: %w", err)
+ }
+ return pk["resource_type"] == 1 && pk["id"] == 2, nil
+}
+
+func rebuildResourcesFTS(ctx context.Context, conn *sql.Conn) error {
+ rows, err := conn.QueryContext(ctx, `SELECT id, resource_type, data FROM resources`)
+ if err != nil {
+ return fmt.Errorf("querying resources: %w", err)
+ }
+
+ type resourceRow struct {
+ id string
+ resourceType string
+ data string
+ }
+ var resources []resourceRow
+ for rows.Next() {
+ var r resourceRow
+ if err := rows.Scan(&r.id, &r.resourceType, &r.data); err != nil {
+ rows.Close()
+ return fmt.Errorf("scanning resource: %w", err)
+ }
+ resources = append(resources, r)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return fmt.Errorf("reading resource rows: %w", err)
+ }
+ if err := rows.Close(); err != nil {
+ return fmt.Errorf("closing resource rows: %w", err)
+ }
+
+ for _, r := range resources {
+ if _, err := conn.ExecContext(ctx,
+ `INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (?, ?, ?, ?)`,
+ ftsRowID(r.resourceType, r.id), r.id, r.resourceType, searchableResourceContent(json.RawMessage(r.data)),
+ ); err != nil {
+ return fmt.Errorf("indexing resource %s/%s: %w", r.resourceType, r.id, err)
+ }
+ }
+ return nil
+}
+
+const (
+ migrationLockTimeout = 30 * time.Second
+ migrationLockBackoffMin = 5 * time.Millisecond
+ migrationLockBackoffMax = 100 * time.Millisecond
+)
+
+// withMigrationLock runs fn inside a BEGIN IMMEDIATE / COMMIT pair on
+// conn, retrying both BEGIN and COMMIT on SQLITE_BUSY against the
+// caller-provided deadline. Sharing the deadline with the pre-lock
+// version read keeps total Open() latency bounded by a single budget.
+// The real upper bound is deadline + one trailing backoff interval
+// (≤100ms) + the driver's busy_timeout for the in-flight Exec, since
+// the deadline is checked after each failed attempt rather than as a
+// hard wall-clock cutoff. fn must use conn (not s.db) so its writes
+// participate in the held transaction.
+func withMigrationLock(ctx context.Context, conn *sql.Conn, deadline time.Time, fn func() error) error {
+ if err := execWithBusyRetry(ctx, conn, "BEGIN IMMEDIATE", "begin migration transaction", deadline); err != nil {
+ return err
+ }
+ committed := false
+ defer func() {
+ if committed {
+ return
+ }
+ // ROLLBACK uses context.Background() so caller-context cancellation
+ // can't strand the connection in an open transaction. A failed
+ // rollback is rare on local SQLite (broken file handle, fatal
+ // driver error) but worth surfacing — silent swallow leaves a
+ // pinned connection returned to the pool with state that will
+ // confuse later queries.
+ if _, rerr := conn.ExecContext(context.Background(), "ROLLBACK"); rerr != nil {
+ fmt.Fprintf(os.Stderr, "warning: store migration rollback failed: %v\n", rerr)
+ }
+ }()
+
+ if err := fn(); err != nil {
+ return err
+ }
+
+ if err := execWithBusyRetry(ctx, conn, "COMMIT", "commit migration transaction", deadline); err != nil {
+ return err
+ }
+ committed = true
+ return nil
+}
+
+// execWithBusyRetry runs stmt on conn and retries on SQLITE_BUSY until
+// deadline. It covers BEGIN IMMEDIATE and COMMIT contention;
+// modernc.org/sqlite's busy_timeout does not reliably cover either when
+// multiple connections race for the WAL write lock.
+func execWithBusyRetry(ctx context.Context, conn *sql.Conn, stmt, label string, deadline time.Time) error {
+ return retryOnBusy(ctx, deadline, label, func() error {
+ _, err := conn.ExecContext(ctx, stmt)
+ return err
+ })
+}
+
+// retryOnBusy runs op and retries it on SQLITE_BUSY/LOCKED until
+// deadline. The same retry shape covers Exec, Query, and any other
+// SQLite call that can race the WAL writer lock — including the
+// pre-lock user_version read, where the WAL initialization race on a
+// fresh DB can BUSY a SELECT that should otherwise succeed under WAL
+// reader/writer concurrency.
+func retryOnBusy(ctx context.Context, deadline time.Time, label string, op func() error) error {
+ backoff := migrationLockBackoffMin
+ for {
+ err := op()
+ if err == nil {
+ return nil
+ }
+ if !isSQLiteBusy(err) {
+ return fmt.Errorf("%s: %w", label, err)
+ }
+ if time.Now().After(deadline) {
+ // The label carries the operation context (e.g. "begin
+ // migration transaction", "reading schema version") — we
+ // don't hardcode "waiting for write lock" because pre-lock
+ // reads also flow through this helper.
+ return fmt.Errorf("%s: timed out after %s under SQLite contention: %w", label, migrationLockTimeout, err)
+ }
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("%s: %w", label, ctx.Err())
+ case <-time.After(backoff):
+ }
+ backoff = min(backoff*2, migrationLockBackoffMax)
+ }
+}
+
+// isSQLiteBusy reports whether err is a retryable SQLite lock condition.
+// Covers both the file-level WAL writer race (SQLITE_BUSY / "database is
+// locked") and the table-level shared-cache contention (SQLITE_LOCKED /
+// "database table is locked"). The match is on the error string because
+// modernc.org/sqlite does not export an error type the generated code
+// can switch on without dragging the driver package into every store
+// consumer.
+func isSQLiteBusy(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "SQLITE_BUSY") ||
+ strings.Contains(msg, "SQLITE_LOCKED") ||
+ strings.Contains(msg, "database is locked") ||
+ strings.Contains(msg, "database table is locked")
+}
+
+func (s *Store) upsertGenericResourceTx(tx *sql.Tx, resourceType, id string, data json.RawMessage) error {
+ _, err := tx.Exec(
+ `INSERT INTO resources (id, resource_type, data, synced_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(resource_type, id) DO UPDATE SET data = excluded.data, synced_at = excluded.synced_at, updated_at = excluded.updated_at`,
+ id, resourceType, string(data), time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
+ )
+ if err != nil {
+ return err
+ }
+
+ ftsRowid := ftsRowID(resourceType, id)
+ // Use explicit rowid for FTS5 compatibility with modernc.org/sqlite.
+ // Standard DELETE WHERE column=? may not work on FTS5 virtual tables.
+ if _, err = tx.Exec(`DELETE FROM resources_fts WHERE rowid = ?`, ftsRowid); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: FTS index cleanup failed: %v\n", err)
+ }
+
+ if _, err = tx.Exec(
+ `INSERT INTO resources_fts (rowid, id, resource_type, content)
+ VALUES (?, ?, ?, ?)`,
+ ftsRowid, id, resourceType, searchableResourceContent(data),
+ ); err != nil {
+ // FTS insert failure is non-fatal
+ fmt.Fprintf(os.Stderr, "warning: FTS index update failed: %v\n", err)
+ }
+
+ return nil
+}
+
+func (s *Store) Upsert(resourceType, id string, data json.RawMessage) error {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ if err := s.upsertGenericResourceTx(tx, resourceType, id, data); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// Propagates sql.ErrNoRows on a miss so callers can distinguish absence from
+// other scan errors via errors.Is.
+func (s *Store) Get(resourceType, id string) (json.RawMessage, error) {
+ var data string
+ err := s.db.QueryRow(
+ `SELECT data FROM resources WHERE resource_type = ? AND id = ?`,
+ resourceType, id,
+ ).Scan(&data)
+ if err != nil {
+ return nil, err
+ }
+ return json.RawMessage(data), nil
+}
+
+// List returns resources of the given type. A positive limit caps the result
+// count; zero or negative means no limit.
+func (s *Store) List(resourceType string, limit int) ([]json.RawMessage, error) {
+ query := `SELECT data FROM resources WHERE resource_type = ? ORDER BY updated_at DESC`
+ args := []any{resourceType}
+ if limit > 0 {
+ query += ` LIMIT ?`
+ args = append(args, limit)
+ }
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var results []json.RawMessage
+ for rows.Next() {
+ var data string
+ if err := rows.Scan(&data); err != nil {
+ return nil, err
+ }
+ results = append(results, json.RawMessage(data))
+ }
+ return results, rows.Err()
+}
+
+func (s *Store) Search(query string, limit int, resourceTypes ...string) ([]json.RawMessage, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ matchQuery := ftsMatchQuery(query)
+ if matchQuery == "" {
+ return nil, nil
+ }
+ resourceType := ""
+ if len(resourceTypes) > 0 {
+ resourceType = strings.TrimSpace(resourceTypes[0])
+ }
+ if resourceType != "" {
+ rows, err := s.db.Query(
+ `SELECT r.data FROM resources r
+ JOIN resources_fts f ON r.id = f.id AND r.resource_type = f.resource_type
+ WHERE resources_fts MATCH ?
+ AND r.resource_type = ?
+ ORDER BY f.rank
+ LIMIT ?`,
+ matchQuery, resourceType, limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var results []json.RawMessage
+ for rows.Next() {
+ var data string
+ if err := rows.Scan(&data); err != nil {
+ return nil, err
+ }
+ results = append(results, json.RawMessage(data))
+ }
+ return results, rows.Err()
+ }
+ rows, err := s.db.Query(
+ `SELECT r.data FROM resources r
+ JOIN resources_fts f ON r.id = f.id AND r.resource_type = f.resource_type
+ WHERE resources_fts MATCH ?
+ ORDER BY f.rank
+ LIMIT ?`,
+ matchQuery, limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var results []json.RawMessage
+ for rows.Next() {
+ var data string
+ if err := rows.Scan(&data); err != nil {
+ return nil, err
+ }
+ results = append(results, json.RawMessage(data))
+ }
+ return results, rows.Err()
+}
+
+func searchableResourceContent(data json.RawMessage) string {
+ dec := json.NewDecoder(bytes.NewReader(data))
+ dec.UseNumber()
+ var value any
+ if err := dec.Decode(&value); err != nil {
+ return ""
+ }
+ var parts []string
+ collectSearchableStrings(&parts, "", value)
+ return strings.Join(parts, " ")
+}
+
+func collectSearchableStrings(parts *[]string, key string, value any) {
+ switch v := value.(type) {
+ case map[string]any:
+ for childKey, child := range v {
+ collectSearchableStrings(parts, childKey, child)
+ }
+ case []any:
+ for _, child := range v {
+ collectSearchableStrings(parts, key, child)
+ }
+ case string:
+ if shouldIndexSearchString(key, v) {
+ *parts = append(*parts, strings.TrimSpace(v))
+ }
+ }
+}
+
+func shouldIndexSearchString(key, value string) bool {
+ s := strings.TrimSpace(value)
+ if len(s) < 2 {
+ return false
+ }
+ if isIdentifierKey(key) {
+ return false
+ }
+ lower := strings.ToLower(s)
+ switch {
+ case IsUUID(s):
+ return false
+ case isoDatePattern.MatchString(s):
+ return false
+ case strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://"):
+ return false
+ }
+ tokens := ftsQueryTokenRE.FindAllString(s, -1)
+ return len(tokens) > 0
+}
+
+func isIdentifierKey(key string) bool {
+ if key == "" {
+ return false
+ }
+ lower := strings.ToLower(key)
+ return lower == "id" ||
+ lower == "uuid" ||
+ strings.HasSuffix(lower, "_id") ||
+ strings.HasSuffix(lower, "-id") ||
+ strings.HasSuffix(key, "Id") ||
+ strings.HasSuffix(key, "ID")
+}
+
+func ftsMatchQuery(query string) string {
+ tokens := ftsQueryTokenRE.FindAllString(query, -1)
+ if len(tokens) == 0 {
+ return ""
+ }
+ quoted := make([]string, 0, len(tokens))
+ for _, token := range tokens {
+ quoted = append(quoted, `"`+token+`"`)
+ }
+ return strings.Join(quoted, " ")
+}
+
+func extractObjectID(obj map[string]any) string {
+ for _, key := range []string{"id", "Id", "ID", "uuid", "slug", "name"} {
+ if v, ok := obj[key]; ok {
+ return ResourceIDString(v)
+ }
+ }
+ return ""
+}
+
+// ftsRowID derives a deterministic rowid from a string ID for use with FTS5.
+// Any change to this derivation requires a StoreSchemaVersion bump and a
+// resources_fts rebuild migration for already-stamped databases.
+// modernc.org/sqlite's FTS5 implementation may not support DELETE WHERE column=?
+// on virtual tables, so we use explicit rowids and DELETE WHERE rowid=? instead.
+func ftsRowID(scope, id string) int64 {
+ h := fnv.New64a()
+ _, _ = h.Write([]byte(scope))
+ _, _ = h.Write([]byte{0}) // separator so ("ab","c") != ("a","bc")
+ _, _ = h.Write([]byte(id))
+ return int64(h.Sum64() & 0x7FFFFFFFFFFFFFFF) // ensure positive
+}
+
+// LookupFieldValue resolves a field value from a JSON object map, trying the
+// snake_case key first, then the camelCase rendering, then the PascalCase
+// rendering. Exported so the sync command's extractID and the upsert path
+// resolve fields the same way — a divergence here produces silent drops on
+// heterogeneous payloads. The PascalCase pass handles .NET-shaped responses
+// (`Id`, `Name`, `OrderId`) without forcing each spec to declare casing.
+func LookupFieldValue(obj map[string]any, snakeKey string) any {
+ if v, ok := obj[snakeKey]; ok {
+ return sqliteFieldValue(v)
+ }
+ parts := strings.Split(snakeKey, "_")
+ for i := 1; i < len(parts); i++ {
+ if parts[i] == "" {
+ continue
+ }
+ parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
+ }
+ camel := strings.Join(parts, "")
+ if v, ok := obj[camel]; ok {
+ return sqliteFieldValue(v)
+ }
+ if parts[0] != "" {
+ pascal := strings.ToUpper(parts[0][:1]) + parts[0][1:] + strings.Join(parts[1:], "")
+ if v, ok := obj[pascal]; ok {
+ return sqliteFieldValue(v)
+ }
+ }
+ return nil
+}
+
+func sqliteFieldValue(v any) any {
+ switch t := v.(type) {
+ case nil, string, bool, int, int64, float64, []byte:
+ return v
+ case json.Number:
+ return strings.TrimSpace(t.String())
+ default:
+ data, err := json.Marshal(v)
+ if err != nil {
+ return fmt.Sprint(v)
+ }
+ return string(data)
+ }
+}
+
+// lookupFieldValue is kept as an unexported alias for in-package callers so
+// the existing UpsertBatch code reads naturally without prefixing every call
+// with the package name.
+func lookupFieldValue(obj map[string]any, snakeKey string) any {
+ return LookupFieldValue(obj, snakeKey)
+}
+
+// DecodeJSONObject decodes data into an object while preserving JSON numbers.
+// Plain json.Unmarshal turns numbers into float64, and fmt on those values can
+// render large integer IDs as scientific notation before they reach resources.id.
+func DecodeJSONObject(data json.RawMessage) (map[string]any, error) {
+ var obj map[string]any
+ dec := json.NewDecoder(bytes.NewReader(data))
+ dec.UseNumber()
+ if err := dec.Decode(&obj); err != nil {
+ return nil, err
+ }
+ return obj, nil
+}
+
+// ResourceIDString returns the stable text form used for resources.id.
+func ResourceIDString(v any) string {
+ switch t := v.(type) {
+ case nil:
+ return ""
+ case json.Number:
+ return strings.TrimSpace(t.String())
+ case float64:
+ if math.IsNaN(t) || math.IsInf(t, 0) {
+ return ""
+ }
+ return strconv.FormatFloat(t, 'f', -1, 64)
+ case float32:
+ f := float64(t)
+ if math.IsNaN(f) || math.IsInf(f, 0) {
+ return ""
+ }
+ return strconv.FormatFloat(f, 'f', -1, 32)
+ default:
+ // fmt.Sprint on typed nil pointers returns ""; callers still guard
+ // that sentinel so unresolved IDs do not become stored resource keys.
+ return strings.TrimSpace(fmt.Sprint(t))
+ }
+}
+
+// upsertAccountTx writes the per-resource domain-table portion of a
+// account upsert inside an existing transaction. The caller is
+// responsible for the generic resources insert (via upsertGenericResourceTx)
+// and for committing the tx. Splitting this out lets UpsertBatch dispatch
+// domain inserts per item without opening a per-item transaction.
+func (s *Store) upsertAccountTx(tx *sql.Tx, id string, obj map[string]any, data json.RawMessage) error {
+ if _, err := tx.Exec(
+ `INSERT INTO "account" ("id", "data", "synced_at", "first_name", "last_name", "email", "metro_id")
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT("id") DO UPDATE SET "data" = excluded."data", "synced_at" = excluded."synced_at", "first_name" = excluded."first_name", "last_name" = excluded."last_name", "email" = excluded."email", "metro_id" = excluded."metro_id"`,
+ id,
+ string(data),
+ time.Now().UTC().Format(time.RFC3339),
+ lookupFieldValue(obj, "first_name"),
+ lookupFieldValue(obj, "last_name"),
+ lookupFieldValue(obj, "email"),
+ lookupFieldValue(obj, "metro_id"),
+ ); err != nil {
+ return fmt.Errorf("insert into account: %w", err)
+ }
+
+ return nil
+}
+
+// UpsertAccount inserts or updates a account record with domain-specific columns.
+func (s *Store) UpsertAccount(data json.RawMessage) error {
+ obj, err := DecodeJSONObject(data)
+ if err != nil {
+ return fmt.Errorf("unmarshaling account: %w", err)
+ }
+
+ id := extractObjectID(obj)
+ if id == "" {
+ return fmt.Errorf("missing id for account")
+ }
+ storageID := resourceStorageID("account", id, obj)
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ if err := s.upsertGenericResourceTx(tx, "account", storageID, data); err != nil {
+ return err
+ }
+ if err := s.upsertAccountTx(tx, storageID, obj, data); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// upsertCategoriesTx writes the per-resource domain-table portion of a
+// categories upsert inside an existing transaction. The caller is
+// responsible for the generic resources insert (via upsertGenericResourceTx)
+// and for committing the tx. Splitting this out lets UpsertBatch dispatch
+// domain inserts per item without opening a per-item transaction.
+func (s *Store) upsertCategoriesTx(tx *sql.Tx, id string, obj map[string]any, data json.RawMessage) error {
+ if _, err := tx.Exec(
+ `INSERT INTO "categories" ("id", "data", "synced_at", "title", "category_name", "category_id", "default_template_id", "top_category", "fallback")
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT("id") DO UPDATE SET "data" = excluded."data", "synced_at" = excluded."synced_at", "title" = excluded."title", "category_name" = excluded."category_name", "category_id" = excluded."category_id", "default_template_id" = excluded."default_template_id", "top_category" = excluded."top_category", "fallback" = excluded."fallback"`,
+ id,
+ string(data),
+ time.Now().UTC().Format(time.RFC3339),
+ lookupFieldValue(obj, "title"),
+ lookupFieldValue(obj, "category_name"),
+ lookupFieldValue(obj, "category_id"),
+ lookupFieldValue(obj, "default_template_id"),
+ lookupFieldValue(obj, "top_category"),
+ lookupFieldValue(obj, "fallback"),
+ ); err != nil {
+ return fmt.Errorf("insert into categories: %w", err)
+ }
+
+ return nil
+}
+
+// UpsertCategories inserts or updates a categories record with domain-specific columns.
+func (s *Store) UpsertCategories(data json.RawMessage) error {
+ obj, err := DecodeJSONObject(data)
+ if err != nil {
+ return fmt.Errorf("unmarshaling categories: %w", err)
+ }
+
+ id := extractObjectID(obj)
+ if id == "" {
+ return fmt.Errorf("missing id for categories")
+ }
+ storageID := resourceStorageID("categories", id, obj)
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ if err := s.upsertGenericResourceTx(tx, "categories", storageID, data); err != nil {
+ return err
+ }
+ if err := s.upsertCategoriesTx(tx, storageID, obj, data); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// upsertSystemTx writes the per-resource domain-table portion of a
+// system upsert inside an existing transaction. The caller is
+// responsible for the generic resources insert (via upsertGenericResourceTx)
+// and for committing the tx. Splitting this out lets UpsertBatch dispatch
+// domain inserts per item without opening a per-item transaction.
+func (s *Store) upsertSystemTx(tx *sql.Tx, id string, obj map[string]any, data json.RawMessage) error {
+ if _, err := tx.Exec(
+ `INSERT INTO "system" ("id", "data", "synced_at", "stream_api_key", "support_key")
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT("id") DO UPDATE SET "data" = excluded."data", "synced_at" = excluded."synced_at", "stream_api_key" = excluded."stream_api_key", "support_key" = excluded."support_key"`,
+ id,
+ string(data),
+ time.Now().UTC().Format(time.RFC3339),
+ lookupFieldValue(obj, "stream_api_key"),
+ lookupFieldValue(obj, "support_key"),
+ ); err != nil {
+ return fmt.Errorf("insert into system: %w", err)
+ }
+
+ return nil
+}
+
+// UpsertSystem inserts or updates a system record with domain-specific columns.
+func (s *Store) UpsertSystem(data json.RawMessage) error {
+ obj, err := DecodeJSONObject(data)
+ if err != nil {
+ return fmt.Errorf("unmarshaling system: %w", err)
+ }
+
+ id := extractObjectID(obj)
+ if id == "" {
+ return fmt.Errorf("missing id for system")
+ }
+ storageID := resourceStorageID("system", id, obj)
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ if err := s.upsertGenericResourceTx(tx, "system", storageID, data); err != nil {
+ return err
+ }
+ if err := s.upsertSystemTx(tx, storageID, obj, data); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// upsertTaskersTx writes the per-resource domain-table portion of a
+// taskers upsert inside an existing transaction. The caller is
+// responsible for the generic resources insert (via upsertGenericResourceTx)
+// and for committing the tx. Splitting this out lets UpsertBatch dispatch
+// domain inserts per item without opening a per-item transaction.
+func (s *Store) upsertTaskersTx(tx *sql.Tx, id string, obj map[string]any, data json.RawMessage) error {
+ if _, err := tx.Exec(
+ `INSERT INTO "taskers" ("id", "data", "synced_at", "name", "hourly_rate", "rating", "review_count", "tasks_completed", "elite", "metro_id")
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT("id") DO UPDATE SET "data" = excluded."data", "synced_at" = excluded."synced_at", "name" = excluded."name", "hourly_rate" = excluded."hourly_rate", "rating" = excluded."rating", "review_count" = excluded."review_count", "tasks_completed" = excluded."tasks_completed", "elite" = excluded."elite", "metro_id" = excluded."metro_id"`,
+ id,
+ string(data),
+ time.Now().UTC().Format(time.RFC3339),
+ lookupFieldValue(obj, "name"),
+ lookupFieldValue(obj, "hourly_rate"),
+ lookupFieldValue(obj, "rating"),
+ lookupFieldValue(obj, "review_count"),
+ lookupFieldValue(obj, "tasks_completed"),
+ lookupFieldValue(obj, "elite"),
+ lookupFieldValue(obj, "metro_id"),
+ ); err != nil {
+ return fmt.Errorf("insert into taskers: %w", err)
+ }
+
+ return nil
+}
+
+// UpsertTaskers inserts or updates a taskers record with domain-specific columns.
+func (s *Store) UpsertTaskers(data json.RawMessage) error {
+ obj, err := DecodeJSONObject(data)
+ if err != nil {
+ return fmt.Errorf("unmarshaling taskers: %w", err)
+ }
+
+ id := extractObjectID(obj)
+ if id == "" {
+ return fmt.Errorf("missing id for taskers")
+ }
+ storageID := resourceStorageID("taskers", id, obj)
+
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ if err := s.upsertGenericResourceTx(tx, "taskers", storageID, data); err != nil {
+ return err
+ }
+ if err := s.upsertTaskersTx(tx, storageID, obj, data); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// resourceIDFieldOverrides projects per-resource IDField (set by the profiler
+// from x-resource-id or response-schema fallback) into a runtime lookup map.
+// UpsertBatch consults this first so the templated path wins over the
+// generic fallback list. Empty when no resource declared an override; the
+// runtime fallback list still applies.
+//
+// Includes both flat resources and dependent (parent-child) resources so a
+// child path-item annotated with x-resource-id resolves the same as a flat
+// path-item.
+var resourceIDFieldOverrides = map[string]string{}
+
+// genericIDFieldFallbacks is the runtime safety net for resources that did
+// NOT receive a templated IDField. API-specific names belong in spec
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "api_id", "name", "slug", "key", "code"}
+
+// resourceParentKeyColumns identifies generated dependent resources whose
+// local mirror rows need the parent context in the storage key. Without this,
+// many-to-many sub-collections collapse every parent association onto the
+// child's bare id and silently keep only the last synced parent.
+var resourceParentKeyColumns = map[string]string{}
+
+// ExtractResourceID resolves the bare resource id field that UpsertBatch
+// extracts from a resource item. For dependent resource types, UpsertBatch
+// derives the actual storage key by combining this id with the parent value;
+// use resourceStorageID if you need the key as it appears in the database.
+// Callers that need to gate best-effort writes can use this to avoid passing
+// non-entity envelopes into the batch path.
+func ExtractResourceID(resourceType string, obj map[string]any) string {
+ if override, ok := resourceIDFieldOverrides[resourceType]; ok && override != "" {
+ if v := lookupFieldValue(obj, override); v != nil {
+ s := ResourceIDString(v)
+ if s != "" && s != "" {
+ return s
+ }
+ }
+ }
+ for _, key := range genericIDFieldFallbacks {
+ if v := lookupFieldValue(obj, key); v != nil {
+ s := ResourceIDString(v)
+ if s != "" && s != "" {
+ return s
+ }
+ }
+ }
+ if s := suffixIDFieldFallback(resourceType, obj); s != "" {
+ return s
+ }
+ return ""
+}
+
+// suffixIDFieldFallback resolves an id-less resource that keys on its own
+// "_code" / "_id" / "_key" / "_slug" field (e.g. the
+// "currencies" resource keying on "currency_code" — see #2327). It is scoped to
+// the resource's OWN name so a foreign key like account_id/parent_id is never
+// promoted to the primary key, and it uses direct map lookups in a fixed suffix
+// order so the chosen id is deterministic.
+func suffixIDFieldFallback(resourceType string, obj map[string]any) string {
+ for _, base := range resourceIDBaseNames(resourceType) {
+ for _, suffix := range []string{"_id", "_code", "_key", "_slug"} {
+ if v, ok := obj[base+suffix]; ok {
+ if s := scalarIDString(v); s != "" && s != "" {
+ return s
+ }
+ }
+ }
+ camelBase := lowerCamelResourceIDBase(base)
+ for _, suffix := range []string{"Id", "Code", "Key", "Slug"} {
+ if v, ok := obj[camelBase+suffix]; ok {
+ if s := scalarIDString(v); s != "" && s != "" {
+ return s
+ }
+ }
+ }
+ }
+ return ""
+}
+
+// resourceIDBaseNames returns lowercase candidate singular/plural stems of a
+// resource name to build "_id"-style key probes from (e.g. "currencies"
+// -> ["currencies","currency"]). OpenAPI-/path-derived names can carry a
+// leading verb token ("get-currencies"), so the same probes are also attempted
+// on the de-verbed stem. Minimal English depluralization; the raw name is
+// always included so already-singular names work too.
+func resourceIDBaseNames(resourceType string) []string {
+ r := strings.ToLower(strings.TrimSpace(resourceType))
+ if r == "" {
+ return nil
+ }
+ stems := []string{r}
+ if d := stripLeadingResourceVerb(r); d != "" && d != r {
+ stems = append(stems, d)
+ }
+ var bases []string
+ seen := map[string]bool{}
+ add := func(s string) {
+ if s != "" && !seen[s] {
+ seen[s] = true
+ bases = append(bases, s)
+ }
+ }
+ for _, stem := range stems {
+ add(stem)
+ add(depluralizeResourceStem(stem))
+ }
+ return bases
+}
+
+func stripLeadingResourceVerb(r string) string {
+ for _, verb := range []string{"get", "list", "fetch", "find", "retrieve", "read", "show", "all"} {
+ for _, sep := range []string{"-", "_"} {
+ prefix := verb + sep
+ if strings.HasPrefix(r, prefix) && len(r) > len(prefix) {
+ return r[len(prefix):]
+ }
+ }
+ }
+ return ""
+}
+
+func depluralizeResourceStem(r string) string {
+ switch {
+ case strings.HasSuffix(r, "ies") && len(r) > 3:
+ return strings.TrimSuffix(r, "ies") + "y" // currencies -> currency
+ // Plurals formed by adding "es" to a base ending in s/x/z/ch/sh. The
+ // double-s "sses" guard (not bare "ses") keeps soft-e plurals — where the
+ // singular already ends in a silent "e" (cases, databases, licenses,
+ // purchases) — out of this branch; they fall through to the "-s" case below
+ // (cases -> case, not cas). Trade-off: a genuine "-es" plural of an s-ending
+ // singular (buses, statuses) depluralizes imperfectly, but those are rare as
+ // resource names and this stem only feeds best-effort id-field probing.
+ case strings.HasSuffix(r, "sses") || strings.HasSuffix(r, "xes") ||
+ strings.HasSuffix(r, "zes") || strings.HasSuffix(r, "ches") ||
+ strings.HasSuffix(r, "shes"):
+ return strings.TrimSuffix(r, "es") // classes -> class, boxes -> box, dishes -> dish
+ case strings.HasSuffix(r, "s") && !strings.HasSuffix(r, "ss") && len(r) > 1:
+ return strings.TrimSuffix(r, "s") // languages -> language, cases -> case
+ }
+ return r
+}
+
+func lowerCamelResourceIDBase(base string) string {
+ parts := strings.FieldsFunc(base, func(r rune) bool {
+ return r == '_' || r == '-'
+ })
+ if len(parts) == 0 {
+ return base
+ }
+ for i := range parts {
+ if i == 0 {
+ parts[i] = strings.ToLower(parts[i])
+ continue
+ }
+ parts[i] = strings.ToUpper(parts[i][:1]) + strings.ToLower(parts[i][1:])
+ }
+ return strings.Join(parts, "")
+}
+
+func scalarIDString(value any) string {
+ switch value.(type) {
+ case string, bool, int, int8, int16, int32, int64,
+ uint, uint8, uint16, uint32, uint64,
+ float32, float64, json.Number, []byte:
+ return ResourceIDString(value)
+ default:
+ return ""
+ }
+}
+
+func resourceStorageID(resourceType, id string, obj map[string]any) string {
+ parentKey := resourceParentKeyColumns[resourceType]
+ if parentKey == "" {
+ return id
+ }
+ parentValue := ResourceIDString(lookupFieldValue(obj, parentKey))
+ if parentValue == "" || parentValue == "" {
+ return id
+ }
+ return id + string([]byte{0}) + parentValue
+}
+
+// BareResourceID strips the NUL-delimited parent suffix that resourceStorageID
+// appends to dependent resource types, returning the bare entity id. ListIDs
+// returns composite keys for parent-keyed resources, so callers comparing those
+// ids against bare API ids must run them through this first. For non-composite
+// ids it returns the input unchanged, so it is safe to apply to every id.
+func BareResourceID(storageID string) string {
+ if i := strings.IndexByte(storageID, 0); i >= 0 {
+ return storageID[:i]
+ }
+ return storageID
+}
+
+// childScopeColumnSources maps a typed child table's path-placeholder scope
+// column (the FK the dependent sync injects per item, e.g. "projects_id") to
+// the singular parent-reference field the API body carries natively (e.g.
+// "project"). deriveScopeColumns consults this so write-through cache paths —
+// which pass RAW API items to UpsertBatch and never carry the path-injected
+// scope column — still satisfy the typed table's NOT NULL scope column instead
+// of stranding the row in generic resources.
+var childScopeColumnSources = map[string]string{}
+
+// deriveScopeColumns backfills a typed child table's scope column from the
+// item's own parent reference when path injection is absent. A value already
+// present (valid injection) is never overwritten.
+func deriveScopeColumns(obj map[string]any) {
+ for scopeKey, sourceKey := range childScopeColumnSources {
+ if v := lookupFieldValue(obj, scopeKey); v != nil {
+ if s, ok := v.(string); !ok || s != "" {
+ continue // path injection already supplied a usable value
+ }
+ }
+ src := lookupFieldValue(obj, sourceKey)
+ if src == nil {
+ continue
+ }
+ if s, ok := src.(string); ok && s == "" {
+ continue
+ }
+ obj[scopeKey] = src
+ }
+}
+
+// UpsertBatch inserts or replaces multiple records in a single transaction
+// and returns (stored, extractFailures, err). stored counts rows landed in
+// the generic resources table; extractFailures counts items that survived
+// JSON unmarshal but had no extractable primary key (templated IDField AND
+// generic fallback both missed). callers (sync.go.tmpl) compare these
+// against len(items) to emit the per-item primary_key_unresolved warning
+// and the F4b stored_count_zero_after_extraction probe.
+//
+// For resource types that have a domain-specific typed table, the per-item
+// generic insert is followed by a dispatch to the matching upsertTx
+// inside the same transaction. Without that dispatch, paginated syncs would
+// only populate the generic resources table — typed tables (and indexed
+// columns like parent_id added by dependent-resource sync) would stay empty.
+//
+// Each typed-table dispatch runs inside a per-item SAVEPOINT so a constraint
+// failure in the typed insert (e.g. NOT NULL parent FK when the generator
+// didn't populate the parent path placeholder) rolls back only that typed
+// upsert. The generic resources row inserted just above it survives the
+// rollback, so successful API fetches never strand in memory because one
+// downstream typed table is misconfigured. Failures are surfaced via a
+// trailing stderr warning rather than aborting the batch.
+func (s *Store) UpsertBatch(resourceType string, items []json.RawMessage) (int, int, error) {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, 0, fmt.Errorf("starting batch transaction: %w", err)
+ }
+ defer tx.Rollback()
+
+ var stored, skippedCount, extractFailures, typedFailures int
+ for i, item := range items {
+ obj, err := DecodeJSONObject(item)
+ if err != nil {
+ skippedCount++
+ continue
+ }
+ // Templated IDField wins; generic fallback list runs second when
+ // the override is empty OR the override field is absent on this
+ // particular item (response shape mismatches happen even when the
+ // spec declares x-resource-id).
+ id := ExtractResourceID(resourceType, obj)
+ if id == "" {
+ if unwrappedObj, unwrappedItem, ok := unwrapIDBearingEnvelopeItem(resourceType, item, obj); ok {
+ obj = unwrappedObj
+ item = unwrappedItem
+ id = ExtractResourceID(resourceType, obj)
+ }
+ }
+ if id == "" {
+ skippedCount++
+ extractFailures++
+ continue
+ }
+ storageID := resourceStorageID(resourceType, id, obj)
+
+ if err := s.upsertGenericResourceTx(tx, resourceType, storageID, item); err != nil {
+ // Return the running stored count rather than zero so callers
+ // inspecting partial progress on failure see what already
+ // landed in earlier loop iterations.
+ return stored, extractFailures, fmt.Errorf("upserting %s/%s: %w", resourceType, storageID, err)
+ }
+ stored++
+
+ // Backfill the typed child table's NOT NULL scope column from the item's
+ // own parent reference when the dependent-sync path injection is absent
+ // (write-through cache feeds RAW API items here).
+ deriveScopeColumns(obj)
+
+ savepoint := fmt.Sprintf("pp_typed_%d", i)
+ if _, err := tx.Exec("SAVEPOINT " + savepoint); err != nil {
+ return stored, extractFailures, fmt.Errorf("savepoint begin for %s/%s: %w", resourceType, storageID, err)
+ }
+
+ var typedErr error
+ switch resourceType {
+ case "account":
+ typedErr = s.upsertAccountTx(tx, storageID, obj, item)
+ case "categories":
+ typedErr = s.upsertCategoriesTx(tx, storageID, obj, item)
+ case "system":
+ typedErr = s.upsertSystemTx(tx, storageID, obj, item)
+ case "taskers":
+ typedErr = s.upsertTaskersTx(tx, storageID, obj, item)
+ }
+
+ if typedErr != nil {
+ if _, rbErr := tx.Exec("ROLLBACK TO SAVEPOINT " + savepoint); rbErr != nil {
+ return stored, extractFailures, fmt.Errorf("rollback to savepoint for %s/%s (typed err: %v): %w", resourceType, storageID, typedErr, rbErr)
+ }
+ if _, relErr := tx.Exec("RELEASE SAVEPOINT " + savepoint); relErr != nil {
+ return stored, extractFailures, fmt.Errorf("release savepoint after rollback for %s/%s: %w", resourceType, storageID, relErr)
+ }
+ typedFailures++
+ continue
+ }
+ if _, err := tx.Exec("RELEASE SAVEPOINT " + savepoint); err != nil {
+ return stored, extractFailures, fmt.Errorf("release savepoint for %s/%s: %w", resourceType, storageID, err)
+ }
+ }
+
+ // Warn when every decoded item in a batch lacks an extractable ID — this
+ // likely means the API uses a primary key field we don't recognize yet.
+ // Partial misses still surface through extractFailures so sync can emit
+ // a structured primary_key_unresolved anomaly without spamming stderr for
+ // write-through cache batches that did persist useful rows.
+ if extractFailures > 0 && stored == 0 && len(items) > 0 {
+ fmt.Fprintf(os.Stderr, "warning: %d/%d %s items returned but not cached locally (no extractable ID field; offline lookup against these rows will be incomplete; live queries unaffected)\n", skippedCount, len(items), resourceType)
+ }
+ // Surface typed-table failures without aborting the batch. Generic rows
+ // already committed; only the typed projection failed.
+ if typedFailures > 0 {
+ fmt.Fprintf(os.Stderr, "warning: %d/%d %s items: typed-table upsert failed; generic resources rows preserved\n", typedFailures, len(items), resourceType)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return 0, extractFailures, err
+ }
+ return stored, extractFailures, nil
+}
+
+func unwrapIDBearingEnvelopeItem(resourceType string, item json.RawMessage, obj map[string]any) (map[string]any, json.RawMessage, bool) {
+ var candidate map[string]any
+ candidateKey := ""
+ objectFields := 0
+ for key, value := range obj {
+ inner, ok := value.(map[string]any)
+ if !ok {
+ continue
+ }
+ objectFields++
+ if ExtractResourceID(resourceType, inner) != "" {
+ candidate = inner
+ candidateKey = key
+ }
+ }
+ if objectFields != 1 || candidate == nil || candidateKey == "" {
+ return nil, nil, false
+ }
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(item, &raw); err != nil {
+ return nil, nil, false
+ }
+ data, ok := raw[candidateKey]
+ if !ok {
+ return nil, nil, false
+ }
+ return candidate, data, true
+}
+
+func (s *Store) SaveSyncState(resourceType, cursor string, count int) error {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ _, err := s.db.Exec(
+ `INSERT INTO sync_state (resource_type, last_cursor, last_synced_at, total_count)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(resource_type) DO UPDATE SET last_cursor = excluded.last_cursor,
+ last_synced_at = excluded.last_synced_at, total_count = excluded.total_count`,
+ resourceType, cursor, time.Now().UTC().Format(time.RFC3339), count,
+ )
+ return err
+}
+
+func (s *Store) GetSyncState(resourceType string) (cursor string, lastSynced time.Time, count int, err error) {
+ err = s.db.QueryRow(
+ `SELECT last_cursor, last_synced_at, total_count FROM sync_state WHERE resource_type = ?`,
+ resourceType,
+ ).Scan(&cursor, &lastSynced, &count)
+ if err == sql.ErrNoRows {
+ return "", time.Time{}, 0, nil
+ }
+ return
+}
+
+// SaveSyncCursor stores the pagination cursor for a resource type.
+func (s *Store) SaveSyncCursor(resourceType, cursor string) error {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ now := time.Now().UTC().Format(time.RFC3339)
+ _, err := s.db.Exec(
+ `INSERT INTO sync_state (resource_type, last_cursor, last_synced_at, total_count)
+ VALUES (?, ?, ?, 0)
+ ON CONFLICT(resource_type) DO UPDATE SET last_cursor = ?, last_synced_at = ?`,
+ resourceType, cursor, now, cursor, now,
+ )
+ return err
+}
+
+// GetSyncCursor returns the last pagination cursor for a resource type.
+func (s *Store) GetSyncCursor(resourceType string) string {
+ var cursor sql.NullString
+ s.db.QueryRow("SELECT last_cursor FROM sync_state WHERE resource_type = ?", resourceType).Scan(&cursor)
+ if cursor.Valid {
+ return cursor.String
+ }
+ return ""
+}
+
+// ListIDs returns all IDs from a resource's domain table, or from the generic
+// resources table if no domain table exists. Used by dependent sync to iterate parents.
+// For parent-keyed resource types these are composite storage keys; run them
+// through BareResourceID before comparing against bare API ids.
+//
+// resourceType is never interpolated into SQL directly. We resolve it to a real
+// table name via a parameterized sqlite_master lookup; only that trusted name is
+// substituted (double-quoted) into the SELECT. Callers may pass any string.
+func (s *Store) ListIDs(resourceType string) ([]string, error) {
+ var table string
+ err := s.db.QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
+ resourceType,
+ ).Scan(&table)
+ var rows *sql.Rows
+ if err == nil && table != "" {
+ rows, err = s.db.Query(fmt.Sprintf(`SELECT id FROM "%s"`, strings.ReplaceAll(table, `"`, `""`)))
+ }
+ if err != nil || table == "" {
+ // Fall back to generic resources table
+ rows, err = s.db.Query("SELECT id FROM resources WHERE resource_type = ?", resourceType)
+ if err != nil {
+ return nil, err
+ }
+ }
+ defer rows.Close()
+
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ continue
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+}
+
+// ListIDsScoped is ListIDs with an optional tenant filter. scopeValue=="" =>
+// unscoped (identical to ListIDs). When the typed table exists AND has
+// scopeColumn (validated via validIdentifierRE + pragma_table_info), the IDs are
+// filtered by that bound column. When the typed table exists but LACKS the
+// column, it degrades to unscoped ListIDs (never silently returns zero parents).
+// When no typed table exists, it filters the generic resources table via
+// json_extract. scopeColumn is validated; scopeValue is always bound.
+func (s *Store) ListIDsScoped(resourceType, scopeColumn, scopeValue string) ([]string, error) {
+ if scopeValue == "" || scopeColumn == "" {
+ return s.ListIDs(resourceType)
+ }
+ if !validIdentifierRE.MatchString(scopeColumn) {
+ return nil, fmt.Errorf("ListIDsScoped: invalid scope column %q", scopeColumn)
+ }
+ var table string
+ err := s.db.QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
+ resourceType,
+ ).Scan(&table)
+ if err == nil && table != "" {
+ var colName string
+ colErr := s.db.QueryRow(
+ `SELECT name FROM pragma_table_info(?) WHERE name=?`,
+ table, scopeColumn,
+ ).Scan(&colName)
+ if colErr != nil || colName == "" {
+ // Typed table exists but lacks the scope column: degrade to unscoped
+ // rather than returning zero parents.
+ return s.ListIDs(resourceType)
+ }
+ qTable := strings.ReplaceAll(table, `"`, `""`)
+ qCol := strings.ReplaceAll(colName, `"`, `""`)
+ rows, qerr := s.db.Query(
+ fmt.Sprintf(`SELECT id FROM "%s" WHERE "%s" = ?`, qTable, qCol), scopeValue)
+ if qerr != nil {
+ return nil, qerr
+ }
+ defer rows.Close()
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ continue
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+ }
+ // No typed table: filter the generic resources table by body field.
+ rows, qerr := s.db.Query(
+ fmt.Sprintf(`SELECT id FROM resources WHERE resource_type = ? AND (CASE WHEN json_valid(data) THEN json_extract(data, '$.%s') END) = ?`, scopeColumn),
+ resourceType, scopeValue,
+ )
+ if qerr != nil {
+ return nil, qerr
+ }
+ defer rows.Close()
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ continue
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+}
+
+// ListField returns values of a named field from a resource's domain table,
+// or from the generic resources table via json_extract when no typed column
+// exists. Used by dependent sync to iterate parents when a spec-declared
+// walker extracts a non-PK field (Endpoint.Walker.KeyField in the upstream
+// printing-press repo) for the child path's placeholder.
+//
+// Defense in depth: field is validated against validIdentifierRE at entry
+// — the regex pins it to SQL-safe identifier shape covering both the
+// typed-column primary path AND the json_extract fallback (where
+// pragma_table_info validation would never run if the parent's domain
+// table doesn't exist yet). resourceType is never interpolated into SQL
+// directly; we resolve it to a real table name via a parameterized
+// sqlite_master lookup. Only validated names are substituted
+// (double-quoted) into the SELECT. Mirrors ListIDs's defense pattern so
+// callers may pass any string.
+func (s *Store) ListField(resourceType, field string) ([]string, error) {
+ if !validIdentifierRE.MatchString(field) {
+ return nil, fmt.Errorf("ListField: invalid field name %q (must match %s)", field, validIdentifierRE.String())
+ }
+ var table string
+ err := s.db.QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
+ resourceType,
+ ).Scan(&table)
+ var rows *sql.Rows
+ if err == nil && table != "" {
+ // Validate the column exists on the resolved table before splicing
+ // it into the SELECT. pragma_table_info is parameterizable.
+ var colName string
+ colErr := s.db.QueryRow(
+ `SELECT name FROM pragma_table_info(?) WHERE name=?`,
+ table, field,
+ ).Scan(&colName)
+ if colErr == nil && colName != "" {
+ qTable := strings.ReplaceAll(table, `"`, `""`)
+ qCol := strings.ReplaceAll(colName, `"`, `""`)
+ // DISTINCT: callers iterate the returned values as parent keys
+ // for child-resource fan-out. Multiple parent rows sharing a
+ // key_field value (legal for non-PK fields) would otherwise
+ // cause the child endpoint to be fetched once per duplicate row.
+ rows, err = s.db.Query(fmt.Sprintf(
+ `SELECT DISTINCT "%s" FROM "%s" WHERE "%s" IS NOT NULL AND "%s" != ''`,
+ qCol, qTable, qCol, qCol,
+ ))
+ } else {
+ err = colErr
+ }
+ }
+ if err != nil || rows == nil {
+ // Fall back to generic resources table via json_extract. Path is
+ // Sprintf'd into the SQL string (matches ResolveByName below).
+ // DISTINCT for the same reason as the typed-column path above.
+ fallback := fmt.Sprintf(
+ `SELECT DISTINCT json_extract(data, '$.%s') FROM resources WHERE resource_type = ? AND json_extract(data, '$.%s') IS NOT NULL`,
+ field, field,
+ )
+ rows, err = s.db.Query(fallback, resourceType)
+ if err != nil {
+ return nil, err
+ }
+ }
+ defer rows.Close()
+
+ var values []string
+ for rows.Next() {
+ var v sql.NullString
+ if err := rows.Scan(&v); err == nil && v.Valid && v.String != "" {
+ values = append(values, v.String)
+ }
+ }
+ return values, rows.Err()
+}
+
+// ListFieldSets returns row-correlated values from the generic resources
+// table. Dependent sync uses this for multi-placeholder paths where values
+// such as owner/repo or server/webapp must stay paired per parent row.
+func (s *Store) ListFieldSets(resourceType string, fields []string) ([]map[string]string, error) {
+ if len(fields) == 0 {
+ return nil, nil
+ }
+ for _, field := range fields {
+ if !validIdentifierRE.MatchString(field) {
+ return nil, fmt.Errorf("ListFieldSets: invalid field name %q (must match %s)", field, validIdentifierRE.String())
+ }
+ }
+
+ rows, err := s.db.Query(`SELECT id, data FROM resources WHERE resource_type = ?`, resourceType)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []map[string]string
+ seenRows := map[string]bool{}
+ for rows.Next() {
+ var id string
+ var data []byte
+ if err := rows.Scan(&id, &data); err != nil {
+ return nil, err
+ }
+ var obj map[string]any
+ if len(data) > 0 {
+ var err error
+ obj, err = DecodeJSONObject(data)
+ if err != nil {
+ return nil, fmt.Errorf("decode %s parent row %s: %w", resourceType, id, err)
+ }
+ }
+ values := make(map[string]string, len(fields))
+ complete := true
+ for _, field := range fields {
+ var value any
+ if field == "id" {
+ value = id
+ } else {
+ value = LookupFieldValue(obj, field)
+ }
+ valueString := ResourceIDString(value)
+ if value == nil || valueString == "" {
+ complete = false
+ break
+ }
+ values[field] = valueString
+ }
+ if complete {
+ keyParts := make([]string, 0, len(fields))
+ for _, field := range fields {
+ keyParts = append(keyParts, values[field])
+ }
+ key := strings.Join(keyParts, "\x00")
+ if seenRows[key] {
+ continue
+ }
+ seenRows[key] = true
+ out = append(out, values)
+ }
+ }
+ return out, rows.Err()
+}
+
+// GetLastSyncedAt returns the last sync timestamp for a resource type.
+func (s *Store) GetLastSyncedAt(resourceType string) string {
+ var ts sql.NullString
+ s.db.QueryRow("SELECT last_synced_at FROM sync_state WHERE resource_type = ?", resourceType).Scan(&ts)
+ if ts.Valid {
+ return ts.String
+ }
+ return ""
+}
+
+// ClearSyncCursors resets all sync state for a full resync.
+func (s *Store) ClearSyncCursors() error {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ _, err := s.db.Exec("DELETE FROM sync_state")
+ return err
+}
+
+// Query executes a raw SQL query and returns the rows.
+// Used by workflow commands that need custom queries against the local store.
+func (s *Store) Query(query string, args ...any) (*sql.Rows, error) {
+ return s.db.Query(query, args...)
+}
+
+func (s *Store) Count(resourceType string) (int, error) {
+ var count int
+ err := s.db.QueryRow(
+ `SELECT COUNT(*) FROM resources WHERE resource_type = ?`,
+ resourceType,
+ ).Scan(&count)
+ return count, err
+}
+
+func (s *Store) Status() (map[string]int, error) {
+ rows, err := s.db.Query(
+ `SELECT resource_type, COUNT(*) FROM resources GROUP BY resource_type ORDER BY resource_type`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ status := make(map[string]int)
+ for rows.Next() {
+ var rt string
+ var count int
+ if err := rows.Scan(&rt, &count); err != nil {
+ return nil, err
+ }
+ status[rt] = count
+ }
+ return status, rows.Err()
+}
+
+// CascadeJunction names a junction table + the FK column referencing the
+// reconciled resource's primary key, to be cleaned when a row is swept.
+type CascadeJunction struct {
+ Table string
+ FKColumn string
+}
+
+var (
+ cascadeMu sync.Mutex
+ cascadeJunctions = map[string][]CascadeJunction{}
+)
+
+// RegisterCascadeJunction records a junction to clean when rows of resourceType
+// are reconciled away. Used for runtime-created junctions (e.g. module_issues)
+// that the generated schema does not declare.
+//
+// Registration is idempotent: re-registering the same (Table, FKColumn) for a
+// resourceType is a no-op. The registry is a process-global with no removal path
+// (registrations happen once at startup in the generated binary); dedupe keeps a
+// repeated init() or a test that re-registers across sub-tests from accumulating
+// duplicate cascades.
+func RegisterCascadeJunction(resourceType string, j CascadeJunction) {
+ cascadeMu.Lock()
+ defer cascadeMu.Unlock()
+ for _, existing := range cascadeJunctions[resourceType] {
+ if existing == j {
+ return
+ }
+ }
+ cascadeJunctions[resourceType] = append(cascadeJunctions[resourceType], j)
+}
+
+// CascadeJunctionsFor returns the registered cascade junctions for resourceType.
+func CascadeJunctionsFor(resourceType string) []CascadeJunction {
+ cascadeMu.Lock()
+ defer cascadeMu.Unlock()
+ out := make([]CascadeJunction, len(cascadeJunctions[resourceType]))
+ copy(out, cascadeJunctions[resourceType])
+ return out
+}
+
+// ReconcilePartition hard-deletes local rows of resourceType in one partition
+// (rows whose data JSON at genericScopeJSONPath equals scopeValue) whose primary
+// key is NOT in seenIDs. It is the mark-and-sweep half of deletion mirroring;
+// the caller must pass the COMPLETE, successfully-enumerated seen-ID set for the
+// partition. Victims are computed from the generic resources table so that
+// legacy rows lacking a typed projection are also cleaned. Cleans, per victim:
+// the typed table row (firing its AFTER DELETE FTS triggers, if any), the
+// generic resources_fts entry (manual, no triggers), the generic resources row,
+// and each cascade junction. Returns the number of generic rows deleted.
+func (s *Store) ReconcilePartition(resourceType, genericScopeJSONPath, scopeValue string, seenIDs []string, typedTable string, cascades []CascadeJunction) (int, error) {
+ if genericScopeJSONPath == "" || scopeValue == "" {
+ return 0, fmt.Errorf("reconcile %s: empty partition scope", resourceType)
+ }
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+
+ // Seen-set membership is tested in Go, not SQL. Parent-keyed dependent rows
+ // carry a NUL-composite storage id ("\x00", built by
+ // resourceStorageID) while seenIDs holds the BARE API ids sync enumerated, so
+ // each stored id must run through BareResourceID before the comparison. A SQL
+ // seen-set is not viable here: SQLite string functions treat the embedded NUL
+ // as a C-string terminator, so an instr/substr or `IN` test over a key
+ // containing "\x00" silently truncates and mis-matches. BareResourceID is a
+ // no-op for plain ids, so flat/non-composite partitions are unaffected.
+ seen := make(map[string]struct{}, len(seenIDs))
+ for _, id := range seenIDs {
+ seen[id] = struct{}{}
+ }
+
+ // CASE guards against a malformed-JSON row aborting the victim scan:
+ // a row we cannot parse is never a victim — it is skipped (never deleted).
+ rows, err := tx.Query(
+ `SELECT id FROM resources
+ WHERE resource_type = ?
+ AND (CASE WHEN json_valid(data) THEN json_extract(data, ?) END) = ?`,
+ resourceType, genericScopeJSONPath, scopeValue,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("reconcile %s: select victims: %w", resourceType, err)
+ }
+ var victims []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ if _, ok := seen[BareResourceID(id)]; ok {
+ continue // bare id was enumerated this run — keep the row
+ }
+ victims = append(victims, id)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+
+ // Safety: typedTable and cascade Table/FKColumn are TRUSTED generator/registration
+ // metadata (schema-derived or RegisterCascadeJunction), not user input — Sprintf
+ // interpolation here is intentional and safe.
+ for _, id := range victims {
+ if typedTable != "" {
+ if _, err := tx.Exec(fmt.Sprintf(`DELETE FROM "%s" WHERE id = ?`, typedTable), id); err != nil {
+ return 0, fmt.Errorf("reconcile %s: typed delete: %w", resourceType, err)
+ }
+ }
+ if _, err := tx.Exec(`DELETE FROM resources_fts WHERE rowid = ?`, ftsRowID(resourceType, id)); err != nil {
+ return 0, fmt.Errorf("reconcile %s: fts delete: %w", resourceType, err)
+ }
+ if _, err := tx.Exec(`DELETE FROM resources WHERE resource_type = ? AND id = ?`, resourceType, id); err != nil {
+ return 0, fmt.Errorf("reconcile %s: generic delete: %w", resourceType, err)
+ }
+ // Cascade junction FKs hold the BARE entity id, never the NUL-composite
+ // storage key, so strip the suffix before matching (no-op for plain ids).
+ for _, c := range cascades {
+ if _, err := tx.Exec(fmt.Sprintf(`DELETE FROM "%s" WHERE "%s" = ?`, c.Table, c.FKColumn), BareResourceID(id)); err != nil {
+ return 0, fmt.Errorf("reconcile %s: cascade %s: %w", resourceType, c.Table, err)
+ }
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ return 0, err
+ }
+ return len(victims), nil
+}
+
+// ResolveByName resolves a human-readable name to a UUID from synced data.
+// If the input is already a UUID, it is returned as-is.
+// matchFields are JSON field names to search against (e.g., "name", "key", "email").
+//
+// json_extract path components cannot be bound as SQL parameters, so each
+// field is validated against validIdentifierRE before being spliced into
+// the query.
+func (s *Store) ResolveByName(resourceType string, input string, matchFields ...string) (string, error) {
+ if IsUUID(input) {
+ return input, nil
+ }
+
+ var matches []string
+ for _, field := range matchFields {
+ if !validIdentifierRE.MatchString(field) {
+ continue
+ }
+ query := fmt.Sprintf(
+ `SELECT id FROM resources WHERE resource_type = ? AND LOWER(json_extract(data, '$.%s')) = LOWER(?)`,
+ field,
+ )
+ rows, err := s.db.Query(query, resourceType, input)
+ if err != nil {
+ return "", err
+ }
+ for rows.Next() {
+ var id string
+ if rows.Scan(&id) == nil {
+ // Deduplicate
+ found := false
+ for _, m := range matches {
+ if m == id {
+ found = true
+ break
+ }
+ }
+ if !found {
+ matches = append(matches, id)
+ }
+ }
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return "", err
+ }
+ rows.Close()
+ }
+
+ switch len(matches) {
+ case 0:
+ return "", fmt.Errorf("%s %q not found in local store. Run 'sync' first, or use the UUID directly", resourceType, input)
+ case 1:
+ return matches[0], nil
+ default:
+ hint := matches[0]
+ if len(matches) > 5 {
+ hint = strings.Join(matches[:5], ", ") + "..."
+ } else {
+ hint = strings.Join(matches, ", ")
+ }
+ return "", fmt.Errorf("ambiguous: %q matches %d %s entries (%s). Use the exact UUID instead", input, len(matches), resourceType, hint)
+ }
+}
diff --git a/library/productivity/human-goat/internal/store/upsert_batch_test.go b/library/productivity/human-goat/internal/store/upsert_batch_test.go
new file mode 100644
index 0000000000..20d42f6dec
--- /dev/null
+++ b/library/productivity/human-goat/internal/store/upsert_batch_test.go
@@ -0,0 +1,613 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+// TestStoreWrite_NoSQLITE_BUSY_HighConcurrency exercises the writeMu serialization
+// guarantee: 16 fetcher-style goroutines hammer the store with a mix of
+// UpsertBatch, SaveSyncState, and SaveSyncCursor calls. Before the mutex
+// fix, this test reproduces SQLITE_BUSY at default sync concurrency on
+// pure-Go SQLite (modernc.org/sqlite + WAL) because multiple writers
+// race for the WAL lock and busy_timeout retries are not exhaustive.
+//
+// Run under `go test -race` to catch any data races on Store fields.
+func TestStoreWrite_NoSQLITE_BUSY_HighConcurrency(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ const goroutines = 16
+ const itemsPerBatch = 5
+
+ var wg sync.WaitGroup
+ errCh := make(chan error, goroutines*3)
+
+ for g := 0; g < goroutines; g++ {
+ wg.Add(1)
+ go func(gid int) {
+ defer wg.Done()
+ rt := fmt.Sprintf("rt_%d", gid)
+ items := make([]json.RawMessage, 0, itemsPerBatch)
+ for i := 0; i < itemsPerBatch; i++ {
+ items = append(items, json.RawMessage(fmt.Sprintf(`{"id": "g%d-i%d"}`, gid, i)))
+ }
+ if _, _, err := s.UpsertBatch(rt, items); err != nil {
+ errCh <- fmt.Errorf("UpsertBatch goroutine %d: %w", gid, err)
+ return
+ }
+ if err := s.SaveSyncState(rt, fmt.Sprintf("cursor-%d", gid), itemsPerBatch); err != nil {
+ errCh <- fmt.Errorf("SaveSyncState goroutine %d: %w", gid, err)
+ return
+ }
+ if err := s.SaveSyncCursor(rt, fmt.Sprintf("cursor2-%d", gid)); err != nil {
+ errCh <- fmt.Errorf("SaveSyncCursor goroutine %d: %w", gid, err)
+ return
+ }
+ }(g)
+ }
+ wg.Wait()
+ close(errCh)
+
+ for err := range errCh {
+ if err == nil {
+ continue
+ }
+ // SQLITE_BUSY surfaces as "database is locked" or "SQLITE_BUSY"
+ // in the error message — assert neither occurs.
+ msg := err.Error()
+ if strings.Contains(msg, "SQLITE_BUSY") || strings.Contains(strings.ToLower(msg), "database is locked") {
+ t.Fatalf("got SQLITE_BUSY-class error under concurrent writers: %v", err)
+ }
+ t.Fatalf("unexpected error under concurrent writers: %v", err)
+ }
+
+ // Verify all rows persisted: goroutines * itemsPerBatch in the generic
+ // resources table.
+ db := s.DB()
+ var total int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM resources`).Scan(&total); err != nil {
+ t.Fatalf("count resources: %v", err)
+ }
+ if total != goroutines*itemsPerBatch {
+ t.Fatalf("resources total = %d, want %d", total, goroutines*itemsPerBatch)
+ }
+}
+
+// TestStoreWrite_PanicReleasesLock confirms that a panic inside a locked
+// section unwinds via defer s.writeMu.Unlock() so subsequent writers can
+// proceed. A leaked lock would deadlock the second call indefinitely.
+func TestStoreWrite_PanicReleasesLock(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ // Trigger panic by passing a nil *Store method receiver indirectly:
+ // we call UpsertBatch with malformed JSON that survives Unmarshal
+ // (it's wrapped in skipped-count handling) — there's no easy panic
+ // path inside a locked section that doesn't also corrupt state, so
+ // we instead simulate the post-panic state by manually locking and
+ // unlocking, then assert subsequent calls succeed.
+ func() {
+ defer func() {
+ recover()
+ }()
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ panic("simulated writer panic")
+ }()
+
+ // Subsequent writer must not block.
+ done := make(chan struct{})
+ go func() {
+ if _, _, err := s.UpsertBatch("post_panic", []json.RawMessage{json.RawMessage(`{"id": "x"}`)}); err != nil {
+ t.Errorf("post-panic UpsertBatch: %v", err)
+ }
+ close(done)
+ }()
+ <-done
+}
+
+// TestUpsertBatch_TemplatedIDFieldOverrideWins exercises the
+// per-resource ID-field override. When the spec author annotates a
+// path-item with x-resource-id, the profiler emits SyncableResource.IDField,
+// the generator templates this into resourceIDFieldOverrides, and
+// UpsertBatch consults that map first. This test seeds the override map
+// at runtime (since the generated table here may or may not declare any
+// override) to assert the lookup path itself works.
+func TestUpsertBatch_TemplatedIDFieldOverrideWins(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ // Inject a runtime override for a synthetic resource. Item carries
+ // no generic-fallback field (no id/name/uuid/...) — only a custom
+ // "ticker" field. Without the override, all 3 items would be
+ // dropped as PK-unresolved; with it, all 3 land.
+ prev, hadPrev := resourceIDFieldOverrides["overrideTest"]
+ resourceIDFieldOverrides["overrideTest"] = "ticker"
+ defer func() {
+ if hadPrev {
+ resourceIDFieldOverrides["overrideTest"] = prev
+ } else {
+ delete(resourceIDFieldOverrides, "overrideTest")
+ }
+ }()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"ticker": "AAPL", "price": 100}`),
+ json.RawMessage(`{"ticker": "GOOG", "price": 200}`),
+ json.RawMessage(`{"ticker": "MSFT", "price": 300}`),
+ }
+ stored, extractFailures, err := s.UpsertBatch("overrideTest", items)
+ if err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+ if stored != 3 {
+ t.Fatalf("stored = %d, want 3 (templated override should resolve all PKs)", stored)
+ }
+ if extractFailures != 0 {
+ t.Fatalf("extractFailures = %d, want 0", extractFailures)
+ }
+}
+
+// TestUpsertBatch_GenericFallbackList covers each name in the reduced
+// fallback list. The kalshi-accreted names (ticker/event_ticker/series_ticker)
+// were dropped because the user owns kalshi and will regenerate
+// it with x-resource-id annotations; this test pins what the generic list
+// is now responsible for so a future trim doesn't silently break unannotated
+// specs.
+func TestUpsertBatch_GenericFallbackList(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ for _, key := range []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "api_id", "name", "slug", "key", "code"} {
+ t.Run(key, func(t *testing.T) {
+ rt := "fallback_" + key
+ items := []json.RawMessage{
+ json.RawMessage(fmt.Sprintf(`{%q: %q}`, key, "value-1")),
+ json.RawMessage(fmt.Sprintf(`{%q: %q}`, key, "value-2")),
+ }
+ stored, extractFailures, err := s.UpsertBatch(rt, items)
+ if err != nil {
+ t.Fatalf("UpsertBatch(%q): %v", key, err)
+ }
+ if stored != 2 {
+ t.Fatalf("stored = %d, want 2 (fallback %q must resolve)", stored, key)
+ }
+ if extractFailures != 0 {
+ t.Fatalf("extractFailures = %d, want 0", extractFailures)
+ }
+ })
+ }
+
+ // Negative: API-specific names dropped must NOT resolve.
+ // Spec authors annotate these via x-resource-id instead.
+ for _, key := range []string{"ticker", "event_ticker", "series_ticker"} {
+ t.Run("dropped_"+key, func(t *testing.T) {
+ rt := "dropped_" + key
+ items := []json.RawMessage{
+ json.RawMessage(fmt.Sprintf(`{%q: %q}`, key, "v1")),
+ }
+ stored, extractFailures, err := s.UpsertBatch(rt, items)
+ if err != nil {
+ t.Fatalf("UpsertBatch(%q): %v", key, err)
+ }
+ if stored != 0 {
+ t.Fatalf("stored = %d, want 0 (%q must NOT be in the generic fallback list)", stored, key)
+ }
+ if extractFailures != 1 {
+ t.Fatalf("extractFailures = %d, want 1 (%q drop must surface as extract failure)", extractFailures, key)
+ }
+ })
+ }
+}
+
+func TestUpsertBatch_SuffixFallbackAcceptsScopedCamelCaseID(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"deploymentId": "dep-1", "status": "running"}`),
+ }
+ stored, extractFailures, err := s.UpsertBatch("deployments", items)
+ if err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+ if stored != 1 || extractFailures != 0 {
+ t.Fatalf("deploymentId fallback stored=%d extractFailures=%d, want stored=1 extractFailures=0", stored, extractFailures)
+ }
+ row, err := s.Get("deployments", "dep-1")
+ if err != nil {
+ t.Fatalf("Get deployment row: %v", err)
+ }
+ if !strings.Contains(string(row), `"deploymentId"`) || !strings.Contains(string(row), `"dep-1"`) {
+ t.Fatalf("cached row should preserve original object, got %s", row)
+ }
+
+ stored, extractFailures, err = s.UpsertBatch("deployments", []json.RawMessage{
+ json.RawMessage(`{"parentId": "parent-1", "status": "foreign-key-only"}`),
+ })
+ if err != nil {
+ t.Fatalf("UpsertBatch parentId: %v", err)
+ }
+ if stored != 0 || extractFailures != 1 {
+ t.Fatalf("parentId must not be promoted for deployments: stored=%d extractFailures=%d", stored, extractFailures)
+ }
+}
+
+func TestUpsertBatch_UnwrapsIDBearingEnvelopeItems(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"customer":{"id":"cust-1","name":"Ada"}}`),
+ json.RawMessage(`{"kind":"customer","data":{"id":"cust-2","name":"Grace"}}`),
+ json.RawMessage(`{"customer":{"id":"cust-large","external_id":9007199254740993,"name":"Big"}}`),
+ json.RawMessage(`{"kind":"customer","data":{"description":"missing-id"}}`),
+ json.RawMessage(`{"kind":"customer","data":{"id":"cust-3"},"alternate":{"id":"other-1"}}`),
+ }
+ stored, extractFailures, err := s.UpsertBatch("customers", items)
+ if err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+ if stored != 3 || extractFailures != 2 {
+ t.Fatalf("envelope unwrap stored=%d extractFailures=%d, want stored=3 extractFailures=2", stored, extractFailures)
+ }
+
+ row, err := s.Get("customers", "cust-1")
+ if err != nil {
+ t.Fatalf("Get cust-1: %v", err)
+ }
+ if strings.Contains(string(row), `"customer"`) || !strings.Contains(string(row), `"name":"Ada"`) {
+ t.Fatalf("single-key envelope should store the flat inner object, got %s", row)
+ }
+
+ row, err = s.Get("customers", "cust-2")
+ if err != nil {
+ t.Fatalf("Get cust-2: %v", err)
+ }
+ if strings.Contains(string(row), `"kind"`) || !strings.Contains(string(row), `"name":"Grace"`) {
+ t.Fatalf("tagged envelope should store the flat inner object, got %s", row)
+ }
+
+ row, err = s.Get("customers", "cust-large")
+ if err != nil {
+ t.Fatalf("Get cust-large: %v", err)
+ }
+ if !strings.Contains(string(row), `"external_id":9007199254740993`) || strings.Contains(string(row), `9007199254740992`) {
+ t.Fatalf("envelope unwrap should preserve original large integer bytes, got %s", row)
+ }
+}
+
+func TestUpsertBatch_PreservesLargeIntegerResourceIDs(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"id": 55043301, "name": "large"}`),
+ json.RawMessage(`{"id": 100, "name": "small"}`),
+ json.RawMessage(`{"id": 7, "name": "tiny"}`),
+ }
+ stored, extractFailures, err := s.UpsertBatch("numeric_ids", items)
+ if err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+ if stored != len(items) {
+ t.Fatalf("stored = %d, want %d", stored, len(items))
+ }
+ if extractFailures != 0 {
+ t.Fatalf("extractFailures = %d, want 0", extractFailures)
+ }
+
+ rows, err := s.DB().Query(`SELECT id FROM resources WHERE resource_type = ? ORDER BY CAST(id AS INTEGER)`, "numeric_ids")
+ if err != nil {
+ t.Fatalf("query resources: %v", err)
+ }
+ defer rows.Close()
+
+ var got []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ t.Fatalf("scan id: %v", err)
+ }
+ got = append(got, id)
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("rows: %v", err)
+ }
+ want := []string{"7", "100", "55043301"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("resource ids = %v, want %v", got, want)
+ }
+
+ var literalMatches int
+ if err := s.DB().QueryRow(
+ `SELECT COUNT(*) FROM resources WHERE resource_type = ? AND id IN ('55043301', '100', '7')`,
+ "numeric_ids",
+ ).Scan(&literalMatches); err != nil {
+ t.Fatalf("count literal id matches: %v", err)
+ }
+ if literalMatches != len(items) {
+ t.Fatalf("literal id matches = %d, want %d", literalMatches, len(items))
+ }
+}
+
+// TestUpsertBatch_ExtractFailuresReturnedForPerItemMisses pins the third
+// return value: items that survive JSON unmarshal but have no extractable
+// PK (templated override AND generic fallback both miss) bump
+// extractFailures. The sync.go.tmpl call site uses this to emit the
+// per-resource primary_key_unresolved sync_anomaly the first time silent
+// drops occur.
+func TestUpsertBatch_ExtractFailuresReturnedForPerItemMisses(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"id": "ok-1"}`),
+ json.RawMessage(`{"some_random_field": "no-pk-here"}`),
+ json.RawMessage(`{"id": "ok-2"}`),
+ json.RawMessage(`{"another_field": 42}`),
+ }
+ stored, extractFailures, err := s.UpsertBatch("mixed_extraction", items)
+ if err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+ if stored != 2 {
+ t.Fatalf("stored = %d, want 2 (only items with id should land)", stored)
+ }
+ if extractFailures != 2 {
+ t.Fatalf("extractFailures = %d, want 2 (two items have no extractable PK)", extractFailures)
+ }
+}
+
+func TestSearchQuotesFTSQuerySyntax(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"id": "ip", "value": "10.0.0.1"}`),
+ json.RawMessage(`{"id": "cidr", "value": "172.16.192.0/18"}`),
+ json.RawMessage(`{"id": "host", "value": "host.example.com"}`),
+ json.RawMessage(`{"id": "email", "value": "user@example.com"}`),
+ json.RawMessage(`{"id": "mac", "value": "aa:bb:cc:dd:ee:ff"}`),
+ json.RawMessage(`{"id": "hyphen", "value": "some-name"}`),
+ json.RawMessage(`{"id": "multi", "value": "error with extra words before timeout"}`),
+ }
+ if stored, failed, err := s.UpsertBatch("search-regression", items); err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ } else if failed != 0 || stored != len(items) {
+ t.Fatalf("UpsertBatch stored=%d failed=%d, want stored=%d failed=0", stored, failed, len(items))
+ }
+
+ for _, query := range []string{
+ "10.0.0.1",
+ "172.16.192.0/18",
+ "host.example.com",
+ "user@example.com",
+ "aa:bb:cc:dd:ee:ff",
+ "some-name",
+ "error timeout",
+ } {
+ results, err := s.Search(query, 10)
+ if err != nil {
+ t.Fatalf("Search(%q): %v", query, err)
+ }
+ if len(results) == 0 {
+ t.Fatalf("Search(%q) returned no results", query)
+ }
+ }
+}
+
+// TestUpsertBatch_PopulatesAccountTable verifies that UpsertBatch
+// dispatches paginated items into both the generic resources table AND the
+// typed account table. Regression for issue #268: before the fix, paginated
+// syncs only filled the generic resources table, so domain commands that
+// query the typed table saw zero rows.
+func TestUpsertBatch_PopulatesAccountTable(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"id": "test-001"}`),
+ json.RawMessage(`{"id": "test-002"}`),
+ json.RawMessage(`{"id": "test-003"}`),
+ }
+ if _, _, err := s.UpsertBatch("account", items); err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+
+ db := s.DB()
+
+ var generic int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM resources WHERE resource_type = ?`, "account").Scan(&generic); err != nil {
+ t.Fatalf("count resources: %v", err)
+ }
+ if generic != len(items) {
+ t.Fatalf("resources count = %d, want %d", generic, len(items))
+ }
+
+ var typed int
+ typedQuery := fmt.Sprintf(`SELECT COUNT(*) FROM "%s"`, "account")
+ if err := db.QueryRow(typedQuery).Scan(&typed); err != nil {
+ t.Fatalf("count account: %v", err)
+ }
+ if typed != len(items) {
+ t.Fatalf("account count = %d, want %d (typed table not populated by UpsertBatch)", typed, len(items))
+ }
+}
+
+// TestUpsertBatch_PopulatesCategoriesTable verifies that UpsertBatch
+// dispatches paginated items into both the generic resources table AND the
+// typed categories table. Regression for issue #268: before the fix, paginated
+// syncs only filled the generic resources table, so domain commands that
+// query the typed table saw zero rows.
+func TestUpsertBatch_PopulatesCategoriesTable(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"id": "test-001"}`),
+ json.RawMessage(`{"id": "test-002"}`),
+ json.RawMessage(`{"id": "test-003"}`),
+ }
+ if _, _, err := s.UpsertBatch("categories", items); err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+
+ db := s.DB()
+
+ var generic int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM resources WHERE resource_type = ?`, "categories").Scan(&generic); err != nil {
+ t.Fatalf("count resources: %v", err)
+ }
+ if generic != len(items) {
+ t.Fatalf("resources count = %d, want %d", generic, len(items))
+ }
+
+ var typed int
+ typedQuery := fmt.Sprintf(`SELECT COUNT(*) FROM "%s"`, "categories")
+ if err := db.QueryRow(typedQuery).Scan(&typed); err != nil {
+ t.Fatalf("count categories: %v", err)
+ }
+ if typed != len(items) {
+ t.Fatalf("categories count = %d, want %d (typed table not populated by UpsertBatch)", typed, len(items))
+ }
+}
+
+// TestUpsertBatch_PopulatesSystemTable verifies that UpsertBatch
+// dispatches paginated items into both the generic resources table AND the
+// typed system table. Regression for issue #268: before the fix, paginated
+// syncs only filled the generic resources table, so domain commands that
+// query the typed table saw zero rows.
+func TestUpsertBatch_PopulatesSystemTable(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"id": "test-001"}`),
+ json.RawMessage(`{"id": "test-002"}`),
+ json.RawMessage(`{"id": "test-003"}`),
+ }
+ if _, _, err := s.UpsertBatch("system", items); err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+
+ db := s.DB()
+
+ var generic int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM resources WHERE resource_type = ?`, "system").Scan(&generic); err != nil {
+ t.Fatalf("count resources: %v", err)
+ }
+ if generic != len(items) {
+ t.Fatalf("resources count = %d, want %d", generic, len(items))
+ }
+
+ var typed int
+ typedQuery := fmt.Sprintf(`SELECT COUNT(*) FROM "%s"`, "system")
+ if err := db.QueryRow(typedQuery).Scan(&typed); err != nil {
+ t.Fatalf("count system: %v", err)
+ }
+ if typed != len(items) {
+ t.Fatalf("system count = %d, want %d (typed table not populated by UpsertBatch)", typed, len(items))
+ }
+}
+
+// TestUpsertBatch_PopulatesTaskersTable verifies that UpsertBatch
+// dispatches paginated items into both the generic resources table AND the
+// typed taskers table. Regression for issue #268: before the fix, paginated
+// syncs only filled the generic resources table, so domain commands that
+// query the typed table saw zero rows.
+func TestUpsertBatch_PopulatesTaskersTable(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`{"id": "test-001"}`),
+ json.RawMessage(`{"id": "test-002"}`),
+ json.RawMessage(`{"id": "test-003"}`),
+ }
+ if _, _, err := s.UpsertBatch("taskers", items); err != nil {
+ t.Fatalf("UpsertBatch: %v", err)
+ }
+
+ db := s.DB()
+
+ var generic int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM resources WHERE resource_type = ?`, "taskers").Scan(&generic); err != nil {
+ t.Fatalf("count resources: %v", err)
+ }
+ if generic != len(items) {
+ t.Fatalf("resources count = %d, want %d", generic, len(items))
+ }
+
+ var typed int
+ typedQuery := fmt.Sprintf(`SELECT COUNT(*) FROM "%s"`, "taskers")
+ if err := db.QueryRow(typedQuery).Scan(&typed); err != nil {
+ t.Fatalf("count taskers: %v", err)
+ }
+ if typed != len(items) {
+ t.Fatalf("taskers count = %d, want %d (typed table not populated by UpsertBatch)", typed, len(items))
+ }
+}
diff --git a/library/productivity/human-goat/internal/types/types.go b/library/productivity/human-goat/internal/types/types.go
new file mode 100644
index 0000000000..c8f256c022
--- /dev/null
+++ b/library/productivity/human-goat/internal/types/types.go
@@ -0,0 +1,61 @@
+// Copyright 2026 Matt Van Horn and contributors. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package types
+
+import "encoding/json"
+
+type Account struct {
+ Id string `json:"id"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Email string `json:"email"`
+ MetroId int `json:"metro_id"`
+}
+
+type Booking struct {
+ Id string `json:"id"`
+ Source string `json:"source"`
+ Status string `json:"status"`
+ Title string `json:"title"`
+ TaskerName string `json:"tasker_name"`
+ HourlyRate float64 `json:"hourly_rate"`
+ AllInRate float64 `json:"all_in_rate"`
+ ScheduledAt string `json:"scheduled_at"`
+ CreatedAt string `json:"created_at"`
+}
+
+type Bootstrap struct {
+ Metro json.RawMessage `json:"metro"`
+ PaymentMethodTypes json.RawMessage `json:"payment_method_types"`
+ StreamApiKey string `json:"stream_api_key"`
+ SupportKey string `json:"support_key"`
+}
+
+type Category struct {
+ Title string `json:"title"`
+ CategoryName string `json:"category_name"`
+ CategoryId int `json:"category_id"`
+ DefaultTemplateId int `json:"default_template_id"`
+ TopCategory bool `json:"top_category"`
+ Fallback bool `json:"fallback"`
+}
+
+type Invoice struct {
+ Id string `json:"id"`
+ BookingId string `json:"booking_id"`
+ Amount float64 `json:"amount"`
+ Hours float64 `json:"hours"`
+ CreatedAt string `json:"created_at"`
+}
+
+type Tasker struct {
+ Id string `json:"id"`
+ Name string `json:"name"`
+ HourlyRate float64 `json:"hourly_rate"`
+ Rating float64 `json:"rating"`
+ ReviewCount int `json:"review_count"`
+ TasksCompleted int `json:"tasks_completed"`
+ Elite bool `json:"elite"`
+ MetroId int `json:"metro_id"`
+}
diff --git a/library/productivity/human-goat/manifest.json b/library/productivity/human-goat/manifest.json
new file mode 100644
index 0000000000..e3b23180a0
--- /dev/null
+++ b/library/productivity/human-goat/manifest.json
@@ -0,0 +1,27 @@
+{
+ "manifest_version": "0.3",
+ "name": "human-goat-pp-mcp",
+ "display_name": "Human-Goat",
+ "version": "0.1.0",
+ "description": "Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary.",
+ "author": {
+ "name": "CLI Printing Press"
+ },
+ "license": "Apache-2.0",
+ "server": {
+ "type": "binary",
+ "entry_point": "bin/human-goat-pp-mcp",
+ "mcp_config": {
+ "command": "${__dirname}/bin/human-goat-pp-mcp",
+ "args": []
+ }
+ },
+ "compatibility": {
+ "claude_desktop": ">=1.0.0",
+ "platforms": [
+ "darwin",
+ "linux",
+ "win32"
+ ]
+ }
+}
diff --git a/library/productivity/human-goat/spec.yaml b/library/productivity/human-goat/spec.yaml
new file mode 100644
index 0000000000..b78b418ea4
--- /dev/null
+++ b/library/productivity/human-goat/spec.yaml
@@ -0,0 +1,240 @@
+name: human-goat
+display_name: Human-Goat
+description: |-
+ goat dispatches real-world tasks to real humans and tracks them to completion
+ across two backends: TaskRabbit for in-person local labor (moving, assembly,
+ mounting, cleaning) and Magic for remote errands (phone calls, lookups, online
+ bookings, data entry). The headline capability is autonomous soup-to-nuts hire
+ on TaskRabbit against the card on file, made safe by a verified cancel and a
+ spend cap. TaskRabbit is authenticated via Chrome cookies (session + XSRF-TOKEN,
+ never headless login); Magic via x-api-key.
+version: "0.1.0"
+cli_description: "Hire real humans from the terminal — autonomous TaskRabbit checkout with all-in pricing and a verified undo, plus Magic remote errands, in one agent-native binary."
+category: productivity
+base_url: https://www.taskrabbit.com
+health_check_path: /api/v3/web-client/bootstrap.json
+
+# TaskRabbit is the PRIMARY source (cookie auth). Its REST /api/v3 read surface is
+# modeled below. Everything else is hand-authored (Codex) because it cannot live in
+# one spec:
+# - TaskRabbit tRPC BFF (/next-api/trpc/*): page.tasks.list read + the page.book.*
+# funnel (details/recommendations/schedule/confirm/hire). Envelope
+# [{result:{data:{json:...}}}]; Zod input shapes pinned by the U4 read-only
+# funnel walk. Lives in internal/source/taskrabbit/ with X-CSRF-Token on mutations.
+# - Magic (console.api.getmagic.com/api/v1, x-api-key): send/call/reply/track.
+# Lives in internal/source/magic/ (different base_url + auth than the primary).
+# - Cross-source verbs (dispatch/track/spend) and transcendence (all-in pricing,
+# compare/best, hire, verified cancel, spend cap, watch, rebook).
+
+auth:
+ type: cookie
+ header: Cookie
+ cookie_domain: .taskrabbit.com
+ cookies:
+ - session
+ - XSRF-TOKEN
+
+config:
+ format: toml
+ path: "~/.config/human-goat/config.toml"
+
+resources:
+ system:
+ description: TaskRabbit account bootstrap, metro, and dashboard reachability
+ endpoints:
+ bootstrap:
+ method: GET
+ path: /api/v3/web-client/bootstrap.json
+ description: Account bootstrap — metro (id, name, country), payment_method_types, stream_api_key. Used by doctor to resolve the account metro.
+ response:
+ type: object
+ item: Bootstrap
+ dashboard_counts:
+ method: GET
+ path: /api/v3/web-client/dashboard/counts
+ description: Dashboard tab counts (open tasks, messages, etc.)
+ response:
+ type: object
+
+ account:
+ description: The logged-in TaskRabbit client account profile
+ endpoints:
+ get:
+ method: GET
+ path: /api/v3/account.json
+ description: Get the authenticated account profile
+ response:
+ type: object
+ item: Account
+
+ categories:
+ description: TaskRabbit task categories and templates for the account metro
+ endpoints:
+ list:
+ method: GET
+ path: /api/v3/web-client/metro_task_template.json
+ description: List task templates for the account metro (title, category_name, category_id, default_template_id, top_category)
+ response:
+ type: array
+ item: Category
+ response_path: initial_task_templates
+
+ taskers:
+ description: Favorite, past, and suggested TaskRabbit Taskers
+ endpoints:
+ favorites:
+ method: GET
+ path: /api/v3/poster_rabbit_relationships/favorite
+ description: List your favorited Taskers (poster=client, rabbit=tasker)
+ params:
+ - name: page
+ type: int
+ required: false
+ description: Page number for pagination
+ response:
+ type: array
+ item: Tasker
+ response_path: items
+ pagination:
+ type: page
+ has_more_field: meta.total_pages
+ past:
+ method: GET
+ path: /api/v3/poster_rabbit_relationships/past
+ description: List Taskers you have hired before
+ params:
+ - name: page
+ type: int
+ required: false
+ description: Page number for pagination
+ response:
+ type: array
+ item: Tasker
+ response_path: items
+ pagination:
+ type: page
+ has_more_field: meta.total_pages
+ suggestions:
+ method: GET
+ path: /api/v3/web-client/dashboard/tasker_suggestions
+ description: Tasker suggestions for the account metro
+ response:
+ type: array
+ item: Tasker
+ response_path: items
+
+ invoices:
+ description: Invoice and payment-history flags
+ endpoints:
+ has_submitted:
+ method: GET
+ path: /api/v3/decline_invoice/has_submitted_invoices
+ description: Whether the account has submitted invoices (payment-history presence flag)
+ response:
+ type: object
+
+types:
+ Bootstrap:
+ fields:
+ - name: metro
+ type: object
+ - name: payment_method_types
+ type: array
+ - name: stream_api_key
+ type: string
+ - name: support_key
+ type: string
+ Account:
+ fields:
+ - name: id
+ type: string
+ - name: first_name
+ type: string
+ - name: last_name
+ type: string
+ - name: email
+ type: string
+ - name: metro_id
+ type: int
+ Category:
+ fields:
+ - name: title
+ type: string
+ - name: category_name
+ type: string
+ - name: category_id
+ type: int
+ - name: default_template_id
+ type: int
+ - name: top_category
+ type: bool
+ - name: fallback
+ type: bool
+ Tasker:
+ fields:
+ - name: id
+ type: string
+ - name: name
+ type: string
+ - name: hourly_rate
+ type: float
+ - name: rating
+ type: float
+ - name: review_count
+ type: int
+ - name: tasks_completed
+ type: int
+ - name: elite
+ type: bool
+ - name: metro_id
+ type: int
+ Booking:
+ fields:
+ - name: id
+ type: string
+ - name: source
+ type: string
+ - name: status
+ type: string
+ - name: title
+ type: string
+ - name: tasker_name
+ type: string
+ - name: hourly_rate
+ type: float
+ - name: all_in_rate
+ type: float
+ - name: scheduled_at
+ type: string
+ - name: created_at
+ type: string
+ Invoice:
+ fields:
+ - name: id
+ type: string
+ - name: booking_id
+ type: string
+ - name: amount
+ type: float
+ - name: hours
+ type: float
+ - name: created_at
+ type: string
+
+# Sync: which read endpoints feed the local SQLite store for offline track/spend/ranking.
+# The hand-coded tRPC (bookings) and Magic (remote tasks) adapters also write into the
+# shared store under source=taskrabbit / source=magic.
+sync:
+ cursor:
+ type: time_window
+ description: Bookings by date; tasker search cached per (category, metro, date). Categories rarely change.
+ resources:
+ - categories
+ - taskers
+ - account
+
+# MCP: expose the verbs as agent tools. stdio + http so hosted agents can reach it.
+mcp:
+ transport:
+ - stdio
+ - http
diff --git a/library/productivity/human-goat/tools-manifest.json b/library/productivity/human-goat/tools-manifest.json
new file mode 100644
index 0000000000..0b1bd5ac4b
--- /dev/null
+++ b/library/productivity/human-goat/tools-manifest.json
@@ -0,0 +1,18 @@
+{
+ "api_name": "human-goat",
+ "base_url": "https://www.taskrabbit.com",
+ "description": "Hire real humans from the terminal — autonomous TaskRabbit checkout with a verified undo, plus Magic remote errands, in one agent-native binary.",
+ "mcp_ready": "partial",
+ "http_transport": "standard",
+ "auth": {
+ "type": "cookie",
+ "header": "Cookie",
+ "cookie_domain": ".taskrabbit.com",
+ "cookies": [
+ "session",
+ "XSRF-TOKEN"
+ ]
+ },
+ "required_headers": [],
+ "tools": []
+}