> ## Documentation Index
> Fetch the complete documentation index at: https://gladlabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Cli reference

# Poindexter CLI Reference

**Last Updated:** 2026-06-21

The `poindexter` command is installed as a console script when you
`pip install -e src/cofounder_agent`. It's the primary operator
interface for everything you'd otherwise do by hand with `psql` or
`curl`.

All commands take `--help` for inline documentation. This page is the
consolidated reference.

***

## Quick reference

| Command group | What it does                                                                                                           |
| ------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `setup`       | First-run wizard — generates secrets, tests DB, writes bootstrap                                                       |
| `memory`      | Query and write the shared pgvector memory store                                                                       |
| `tasks`       | Manage the content pipeline task queue                                                                                 |
| `posts`       | Query and manage published/draft blog posts                                                                            |
| `settings`    | Read and write `app_settings` (DB-first config)                                                                        |
| `costs`       | Pipeline spending and operational metrics                                                                              |
| `vercel`      | Vercel deployment status via the REST API                                                                              |
| `premium`     | Manage Poindexter Pro subscription license                                                                             |
| `schedule`    | Scheduled-publish queue (batch/list/show/shift/clear/at) + publish-approval gate (approve/reject/pending/show-pending) |
| `topics`      | Topic-decision approval queue (list/show/approve/reject/propose)                                                       |
| `gates`       | HITL pipeline gates — approve/reject/pending/show + list/set toggles                                                   |
| `migrate`     | Schema migration runner (status / up / down)                                                                           |

Run `poindexter --help` for the top-level list and
`poindexter <group> --help` for subcommands.

> **Deprecated flat aliases (#1652).** The former top-level verbs
> `approve` / `reject` / `list-pending` / `show-pending` (now under `gates`),
> `approve-publish` / `reject-publish` / `list-pending-publish` /
> `show-pending-publish` (now under `schedule`), `publish-at` (now
> `schedule at`), and the singular `post` group (now `posts`) still work as
> **hidden, deprecated aliases** — each prints a one-line deprecation notice to
> stderr and delegates to its grouped command. Update scripts to the grouped
> form; the aliases will be removed in a later cleanup.

***

## `setup`

First-run wizard. Generates secrets, tests DB connectivity, and writes
`~/.poindexter/bootstrap.toml`.

```bash theme={null}
poindexter setup                # Interactive wizard
poindexter setup --auto         # Non-interactive (uses sensible defaults)
poindexter setup --check        # Validate an existing bootstrap.toml
poindexter setup --force        # Overwrite existing config
poindexter setup --db-url postgresql://...   # Pre-populate the DSN
```

The `--auto` mode spins up local Postgres automatically if Docker is
available and no `--db-url` is provided. Use `--check` in CI to
validate that an operator's config is well-formed.

***

## `memory`

Semantic memory store (pgvector). Shared spine for all agents.

### `memory search <query>`

Semantic search across the memory store. Top-k cosine similarity.

```bash theme={null}
poindexter memory search "how do we handle hallucinations"
poindexter memory search "retention policy" --min-similarity 0.7 --limit 10
poindexter memory search "deploy" --source-table audit --writer brain
poindexter memory search "topic" --json-output
```

Flags:

* `--writer TEXT` — filter by writer (e.g. `brain`, `claude`, `operator`)
* `--source-table TEXT` — filter by source (`brain`, `audit`, `posts`, `memory`, `claude_sessions`, `issues`)
* `--min-similarity FLOAT` — default 0.5
* `--limit INT` — default 5
* `--json-output` — machine-readable JSON

### `memory status`

Aggregate counts per `source_table` and per `writer`.

```bash theme={null}
poindexter memory status
poindexter memory status --json-output
```

### `memory store`

Store a memory note.

```bash theme={null}
poindexter memory store --text "Decision: use Ollama-only" --writer operator --tags decision,ollama
poindexter memory store --file notes.md --writer operator
```

Flags:

* `--text TEXT` or `--file PATH` — content source (pick one)
* `--writer TEXT` — required
* `--source-id TEXT` — optional ID to link back
* `--source-table TEXT` — defaults to `memory`
* `--tags TEXT` — comma-separated

### `memory embed <text>`

Print the raw 768-dim embedding vector for debugging.

```bash theme={null}
poindexter memory embed "hello world" --json-output
```

### `memory backfill-posts`

Re-embed all published posts into pgvector (source\_table='posts').

```bash theme={null}
poindexter memory backfill-posts --dry-run
poindexter memory backfill-posts --since 2026-04-01
```

***

## `tasks`

Content pipeline task queue.

### `tasks list`

```bash theme={null}
poindexter tasks list                              # Most recent 20
poindexter tasks list --status awaiting_approval   # Queue for review
poindexter tasks list --limit 50 --json-output
```

### `tasks get <task_id>`

```bash theme={null}
poindexter tasks get 842                # by numeric id
poindexter tasks get 7038e408           # prefix match on UUID
poindexter tasks get 842 --content      # include full post body
poindexter tasks get 842 --json-output
```

### `tasks create <topic>`

Queue a new content task.

```bash theme={null}
poindexter tasks create "Self-hosting Qwen 3 on a 5090" \
  --category technology \
  --target-audience "self-hosting developers" \
  --primary-keyword "self-hosted AI" \
  --style journalistic \
  --tone analytical \
  --target-length 2500
```

### `tasks approve <task_id>` / `tasks reject <task_id>`

```bash theme={null}
poindexter tasks approve 842
poindexter tasks reject 842 --feedback "Off-topic for the brand"
```

`--feedback` is required on reject — the worker API enforces it.

### `tasks publish <task_id>`

Manually publish an approved task (normally handled automatically on
approval, but this is the manual override).

***

## `posts`

Published/draft blog post management.

### `posts create`

Upload a finished markdown post you wrote by hand — the **manual** counterpart
to the AI pipeline (the `create_post` MCP tool / `POST /api/tasks`, which
*generates* a post from a topic). You supply the body; a complete `posts` row is
inserted directly and no writer LLM runs.

```bash theme={null}
# Body from a file; title taken from the first markdown H1 in the file
poindexter posts create --from-file my-post.md --niche ai_ml

# Body piped via stdin, explicit title, routed to the approval queue
cat my-post.md | poindexter posts create \
  --title "Why Docker Changed Everything" \
  --niche pc_hardware --status awaiting_approval

poindexter posts create --from-file my-post.md --niche gaming --json
```

The body is **required** (from `--from-file PATH` or stdin); an empty body fails
loud. Flags:

* `--from-file PATH` — markdown file to upload. Omit to read the body from stdin.
* `--title TEXT` — post title. Omit to derive it from the first markdown `# H1`
  in the body; if neither is present the command fails loud.
* `--slug TEXT` — URL slug. Omit to derive from the slugified title plus a short
  random suffix.
* `--niche SLUG` — stored in `metadata.niche_slug` (e.g. `ai_ml`, `gaming`,
  `pc_hardware`). Omitting it warns — niche-gated publishing skips a post with no
  niche.
* `--excerpt TEXT` — optional excerpt / SEO description.
* `--status {draft,awaiting_approval}` — initial status (default `draft`).
  `awaiting_approval` routes it into the approval queue.
* `--media A,B` — media to generate **at publish time** (`podcast`, `video`,
  `video_short`; `short` is an alias for `video_short`). Defaults to
  `default_media_to_generate`.
* `--force` — bypass the semantic dedup guard **and** idempotency.
* `--json` — machine-readable output.

Two safety rails, shared with the AI path:

* **Semantic dedup** — the resolved title is checked against already-published
  posts and refused if cosine similarity ≥ `create_post_dedup_threshold`
  (default `0.75`). Re-run with `--force` to override.
* **Idempotency (#338)** — a key derived from `body + operator` makes an
  accidental same-file re-upload within
  `cli_post_create_idempotency_window_minutes` (default 30) return the existing
  post instead of a duplicate; an edited body is a new post. `--force` bypasses
  it, or set `cli_post_create_idempotency_enabled=false` to disable.

### `posts list`

```bash theme={null}
poindexter posts list
poindexter posts list --include-drafts --limit 50 --offset 100
poindexter posts list --json-output
```

### `posts search <query>`

```bash theme={null}
poindexter posts search "docker"                   # substring on title+content
poindexter posts search "docker" --semantic        # pgvector similarity
poindexter posts search "docker" --limit 10 --json-output
```

### `posts get <slug>`

```bash theme={null}
poindexter posts get my-post-slug
poindexter posts get my-post-slug --content        # include body
poindexter posts get my-post-slug --json-output
```

### `posts publish <post_id>` / `posts archive <post_id>` / `posts unpublish <post_id>`

```bash theme={null}
poindexter posts publish 481           # status='published', published_at=now
poindexter posts archive 481           # status='archived' (soft delete); the
                                       # live site drops it on the next static
                                       # rebuild / hourly ISR revalidation
poindexter posts unpublish 481         # IMMEDIATE takedown: status published->draft
                                       # AND retire the post's static JSON + bust
                                       # its ISR cache, so the live site drops it now
```

Reach for `unpublish` to roll back a wrong/bad publish right away — it reverts
the post to `draft` **and** pulls its JSON from storage + revalidates, so the
page stops being served immediately (unlike `archive`/the PATCH `status` flip,
which only changes the column and waits for the next rebuild to drop the post).
Idempotent: a post that isn't currently published reports `No change`. Also
exposed as `POST /api/posts/{post_id}/unpublish`.

### `posts retitle <post_id> <title>`

```bash theme={null}
poindexter posts retitle 481 "Why Docker Changed Everything"
```

Useful when you need to fix a title without going through the full
approval queue again (e.g. remove a first-person pronoun caught late).

***

## `settings`

Read and write `app_settings` — the DB-first config plane.

### `settings list`

```bash theme={null}
poindexter settings list
poindexter settings list --category qa              # filter by category
poindexter settings list --search threshold         # substring match
poindexter settings list --include-inactive         # show disabled keys
poindexter settings list --limit 100 --json-output
```

### `settings get <key>`

`--reveal` decrypts an `is_secret` row and prints the bare plaintext to
stdout (a warning goes to stderr), so it's safe to capture in a script —
e.g. a webhook HMAC test-fire. Without `--reveal`, encrypted values stay
redacted as `******* (encrypted)`.

```bash theme={null}
poindexter settings get qa_final_score_threshold
poindexter settings get cli_oauth_client_id --json
poindexter settings get lemon_squeezy_webhook_secret --reveal   # decrypt for a test-fire
```

### `settings set <key> <value>`

Upsert — creates if missing (with `--allow-new`), updates if present.

`--secret` stores the value **encrypted at rest** (pgcrypto) and flags the
row `is_secret=true`, routing through the same path as the per-surface
`<surface> set-secret` commands. The plaintext is never echoed back, the
category defaults to `secrets` (override with `--category`), and
`--allow-new` is implied (operator credentials aren't phantom-key-guarded).
Read one back with `settings get <key> --reveal`.

```bash theme={null}
poindexter settings set qa_final_score_threshold 75
poindexter settings set qa_gate_weight 0 --category qa --description "Gates are veto-only, not scored"

# Encrypted secrets — generic app_settings keys with no dedicated surface row:
poindexter settings set google_oauth_client_secret '<value>' --secret
poindexter settings set acme_api_token '<value>' --secret --category integrations
```

### `settings disable <key>` / `settings enable <key>`

Soft-delete a setting (`is_active=false`) while preserving the value.

```bash theme={null}
poindexter settings disable feature_x_enabled
poindexter settings enable feature_x_enabled
```

***

## `costs`

Spend and operational metrics.

### `costs budget`

Month-to-date spend vs. the configured monthly budget.

```bash theme={null}
poindexter costs budget
poindexter costs budget --json-output
```

### `costs operational`

Task counts, worker state, websocket connections.

```bash theme={null}
poindexter costs operational
poindexter costs operational --json-output
```

***

## `vercel`

Vercel deployment status via the Vercel REST API. Requires
`vercel_token` in `app_settings`.

```bash theme={null}
poindexter vercel deployments
poindexter vercel deployments --limit 10 --target production
poindexter vercel production           # Latest production deploy
poindexter vercel domains              # Linked domains
```

***

## `premium`

Manage your Poindexter Pro subscription license.

### `premium activate <license_key>`

```bash theme={null}
poindexter premium activate lmsqueezy_license_key_here
```

Activates a Lemon Squeezy license and unlocks premium content
(prompts, additional dashboards, book chapters).

### `premium deactivate`

Frees your activation slot (useful when moving to a new machine).

```bash theme={null}
poindexter premium deactivate
```

### `premium status`

Shows the current license state, expiration, and activation count.

```bash theme={null}
poindexter premium status
```

***

## `topics`

Operator interface for the topic-decision approval queue
([Glad-Labs/poindexter#146][issue-146]). When
`pipeline_gate_topic_decision = on`, anticipation\_engine output and
manual proposals both land in the same queue rather than auto-running.
Drain the queue at your own pace.

[issue-146]: https://github.com/Glad-Labs/poindexter/issues/146

The gate is **opt-in**. Enable it with:

```bash theme={null}
poindexter gates set topic_decision on
```

`poindexter gates list` shows two groups: the **DEFAULT PUBLISH GATE**
— the always-on `awaiting_approval` per-post sign-off that actually holds
every post, with its live pending count and the global auto-publish posture
(`auto_publish_threshold`, `require_human_approval`, and any armed niches) —
and the **CONFIGURABLE GATES** table, each toggleable gate with its `STATE`
(`enabled`/`disabled`), `WIRED INTO` (where it fires, e.g. `canonical_blog`,
`seo_refresh`, `scheduled_publisher`), and `PENDING` count. `--json` emits the
full rows (a superset of the table, including each gate's `mechanism`:
`graph-node` / `imperative-hold` / `default`).

With `topic_decision` off, manual `topics propose` calls land at
`status='pending'` and the worker runs them end-to-end exactly like
auto-discovered topics — the gate is purely additive.

### `topics list [--source NAME] [--json]`

Show every topic currently paused at `topic_decision`. Oldest-first so
you work the queue chronologically.

```bash theme={null}
poindexter topics list                      # all pending
poindexter topics list --source manual      # only your hand-typed ones
poindexter topics list --source anticipation_engine
poindexter topics list --json | jq '.[].task_id'
```

Output columns: `TASK_ID` (first 8 chars of UUID), `AGE` (relative —
`5m`, `2h`, `1d`), `SOURCE`, `TOPIC`. Empty queues exit zero with a
friendly message — that's a normal state, not an error.

### `topics show <task_id> [--json]`

Pretty-print the full artifact for one queued topic. The artifact
shape:

```json theme={null}
{
  "topic": "...",
  "primary_keyword": "...",
  "tags": ["...", ...],
  "category_suggestion": "...",
  "source": "anticipation_engine" | "manual" | "...",
  "research_summary": "<= ~200 words, omitted if research hasn't run yet>",
  "score_signals": {
    "novelty": <float|null>,
    "internal_link_potential": <float|null>,
    "category_balance": <string|float|null>
  }
}
```

### `topics approve <task_id> [--feedback TEXT] [--json]`

Approve a queued topic — flips it to `pending` and resumes the
pipeline.

```bash theme={null}
poindexter topics approve abcd1234-5678-...
poindexter topics approve abcd1234 --feedback "great angle for hardware niche"
```

Alias for `poindexter gates approve <task_id> --gate topic_decision` with
the gate name asserted explicitly so a misrouted task fails loudly.

### `topics reject <task_id> [--reason TEXT] [--json]`

Reject a queued topic — flips it to `dismissed` (the topic-decision
gate's reject status) and ends the task.

```bash theme={null}
poindexter topics reject abcd1234 --reason "off-brand for the gaming category"
```

### `topics propose --topic "..." [flags]`

Manually inject a topic into the queue. Lands the same way an
anticipation\_engine auto-proposal would — at
`awaiting_gate='topic_decision'` when the gate is on, otherwise at
`status='pending'`.

```bash theme={null}
poindexter topics propose --topic "Why custom water cooling beats AIOs in 2026" \
  --keyword "custom water cooling" \
  --tags pc-hardware,cooling \
  --category hardware
```

Flags:

* `--topic TEXT` (required) — non-empty topic string.
* `--keyword TEXT` — primary SEO keyword. Falls back to the first tag.
* `--tags A,B,C` — comma-separated tag list.
* `--category SLUG` — category hint (`hardware`, `ai-ml`, `gaming`).
* `--source LABEL` — origin label recorded on the artifact (default
  `manual`); use this to tag queue items by sub-source if you've got
  several injection paths feeding `topics propose`.
* `--target-length N` — target word count for the eventual draft
  (default `1500`).
* `--style` / `--tone` — pipeline parameters (defaults
  `technical` / `professional`).
* `--json` — machine-readable output.

Refuses to propose past `topic_discovery_max_pending` (default `50`)
when the gate is enabled — drain pending topics first. Empty topic
text exits non-zero rather than silently inserting a junk row.

### Worked example — drain the queue

```bash theme={null}
$ poindexter topics list
Topic-decision queue (4 pending):

  TASK_ID    AGE    SOURCE                 TOPIC
  abcd1234   2h     anticipation_engine    Why local LLMs won 2026
  bcde2345   1h     manual                 Liquid cooling for inference rigs
  cdef3456   30m    anticipation_engine    AMD Ryzen X3D vs Threadripper
  defa4567   5m     manual                 Off-brand merchandise news

$ poindexter topics show defa4567
Task defa4567-...
  paused_at      2026-04-26T12:00:00+00:00
  age            5m
  status         in_progress

  topic              Off-brand merchandise news
  primary_keyword    merchandise
  tags               news, merch
  source             manual
  ...

$ poindexter topics approve abcd1234 --feedback "good angle"
Approved topic for task abcd1234 — pipeline resumed.

$ poindexter topics approve bcde2345
Approved topic for task bcde2345 — pipeline resumed.

$ poindexter topics approve cdef3456
Approved topic for task cdef3456 — pipeline resumed.

$ poindexter topics reject defa4567 --reason "off-brand, news/merch"
Rejected topic for task defa4567 → status='dismissed'.
```

### Telegram-side flow

When a topic lands in the queue, the operator gets a Telegram message
(if `telegram_ops` is configured in `webhook_endpoints`) with the
artifact summary. The reply path mirrors the CLI:

* `/approve_topic <task_id>` — equivalent to `topics approve`.
* `/reject_topic <task_id> [reason]` — equivalent to `topics reject`.

The Telegram bot is OpenClaw, which calls into the same
`mcp-server-gladlabs` `topics_approve` / `topics_reject` tools the
CLI uses. Single source of truth: `services.approval_service` /
`services.topic_proposal_service`.

***

## `schedule`

Operator interface for the scheduled-publishing queue
([Glad-Labs/poindexter#147][issue-147]). Poindexter already has a
background loop (`services/scheduled_publisher.py`) that publishes
posts whose `posts.published_at` slot has arrived and whose
`status='scheduled'`. These commands populate, inspect, and rewrite
that queue. The group also hosts the **publish-approval gate** verbs
(`approve` / `reject` / `pending` / `show-pending`) for the post-scheduling
HITL gate.

[issue-147]: https://github.com/Glad-Labs/poindexter/issues/147

A post becomes eligible for batch scheduling once it has been
approved (`status IN ('approved', 'awaiting_approval')`) and has no
publish slot yet (`published_at IS NULL`). The batch command pulls
the oldest-approved-first by default; pass `--ordered-by` to change
the source ordering.

### `schedule at <post_id> <when>`

Schedule a single post. (Formerly the top-level `publish-at`, which
still works as a hidden deprecated alias.)

```bash theme={null}
poindexter schedule at 0fe7… "2026-04-28 09:00"
poindexter schedule at 0fe7… --in 2h
poindexter schedule at 0fe7… --in 7d
poindexter schedule at 0fe7… "tomorrow 9am"
poindexter schedule at 0fe7… "next monday 14:00"
```

Flags:

* `--in DUR` — relative scheduling (`30m`, `2h`, `1d`, `1h30m`).
* `--force` — overwrite an existing schedule.
* `--json` — machine-readable output.

### `schedule approve` / `reject` / `pending` / `show-pending`

Operate the `final_publish_approval` gate on the `posts` table — the operator's
last veto before a **scheduled** post is auto-promoted to `published`. When the
gate is enabled, `scheduled_publisher` pauses each due scheduled post here
instead of publishing it (writing `posts.awaiting_gate`), notifies the operator,
and waits; approve clears the gate and the next publisher tick (≤60s) promotes
it. This gate only fires on the scheduled→published timer path — the manual
approve→go-live flow is already a human action and is unaffected. Formerly the
top-level `approve-publish` / `reject-publish` / `list-pending-publish` /
`show-pending-publish`, which still work as hidden deprecated aliases.

```bash theme={null}
poindexter schedule pending                       # list posts at the gate
poindexter schedule show-pending <post_id>        # gate state + artifact
poindexter schedule approve <post_id> --feedback "ship it"
poindexter schedule reject  <post_id> --reason "off-brand"
```

Enable the gate like any other: `poindexter gates set final_publish_approval on`.

### `schedule batch`

Bulk-assign slots to the approved queue.

```bash theme={null}
poindexter schedule batch \
  --count 20 --interval 30m --start "tomorrow 9am" \
  --quiet-hours 22:00-07:00
```

Flags:

* `--count N` (required) — how many posts to schedule.
* `--interval DUR` (required) — slot spacing (`30m`, `1h`, `1h30m`, `1d`).
* `--start TIME` (required) — first slot. ISO 8601, `now`,
  `tomorrow 9am`, or `next monday 14:00`.
* `--quiet-hours HH:MM-HH:MM` — slots inside the window are skipped
  to the next allowed time. Falls back to the `publish_quiet_hours`
  app\_setting when omitted.
* `--ordered-by` — `approved_at` (default) | `created_at` | `id` |
  `title`.
* `--force` — re-schedule posts that already have a slot.
* `--json` — machine-readable output.

If no posts are eligible the command exits non-zero with an
informative message — no silent no-op.

### `schedule list [--all] [--json]`

Print the upcoming queue in publish-time order. Pass `--all` to
include past schedules.

### `schedule show <post_id> [--json]`

Schedule + status detail for a single post.

### `schedule shift <post_id> --by DUR` / `schedule shift --all --by DUR`

Push one schedule (or every still-future scheduled post with `--all`)
back by `DUR`. Past slots are intentionally untouched — shifting an
already-published post does not un-publish it.

### `schedule clear <post_id>` / `schedule clear --all`

Drop the schedule on a single post (or every still-future scheduled
post). Clearing rolls the row back to `status='approved'` so it can
be re-scheduled.

### Worked examples

**1. Schedule 20 approved posts to publish every 30m starting tomorrow
at 9am, skipping 22:00-07:00 quiet hours**

```bash theme={null}
poindexter schedule batch \
  --count 20 \
  --interval 30m \
  --start "tomorrow 9am" \
  --quiet-hours 22:00-07:00
```

Slots that would land between 22:00 and 07:00 jump forward to the
next 07:00; subsequent slots step from there. So if the 11th slot
would be 02:00, it becomes 07:00 the next morning, and slots 12, 13…
continue at 07:30, 08:00, …

**2. Show what's scheduled in the next 7 days**

```bash theme={null}
poindexter schedule list --json | jq '
  .rows
  | map(select(.published_at <= (now + 7*86400 | strftime("%Y-%m-%dT%H:%M:%S+00:00"))))
'
```

For a quick human-readable view, plain `poindexter schedule list`
prints publish-time-ordered rows.

**3. Push the entire queue back by 1 hour**

```bash theme={null}
poindexter schedule shift --all --by 1h
```

Every post still in the future has its `published_at` advanced by
one hour. The audit trail records the operation as a single
`schedule.shifted` event with the post-id list in the payload.

***

## `migrate`

Schema migration runner ([Glad-Labs/poindexter#226][issue-226]). The
worker auto-runs migrations on boot, but operators who don't restart
their container can have unapplied migrations sitting on disk. This
group exposes the same runner as a direct CLI surface — no worker
round-trip, no container restart.

[issue-226]: https://github.com/Glad-Labs/poindexter/issues/226

The CLI operates on the same `services/migrations/` directory and the
`schema_migrations` tracking table the worker uses, so it's safe to
mix and match (CLI `migrate up` then a worker reboot, or vice versa).

### `migrate status [--json]`

List every migration file on disk alongside its applied state.

```bash theme={null}
poindexter migrate status
poindexter migrate status --json
```

Sample output:

```
[x] 0000_baseline.py                                   applied 2026-05-08
[x] 20260510_040315_seed_rag_engine_master_switch.py   applied 2026-05-10
[x] 20260510_044707_seed_default_template_slug.py      applied 2026-05-10
[x] 20260510_065631_drop_experiments_tables.py         applied 2026-05-10
[ ] 20260523_120000_add_new_thing.py                   pending

Total: 5 migrations — 4 applied, 1 pending
```

`0000_baseline.py` is the only legacy 4-digit file remaining — it
captures the squashed history (collapsed into one baseline, re-rolled
most recently by the Phase F squash on 2026-06-22). Every NEW migration uses a UTC timestamp
prefix `YYYYMMDD_HHMMSS_<slug>.py` per Glad-Labs/poindexter#378
(2026-05-05). See [`migrations.md`](migrations) for the naming
convention.

### `migrate up [--to NAME] [--json]`

Apply all pending migrations. Idempotent — already-applied migrations
are skipped via `schema_migrations`.

```bash theme={null}
poindexter migrate up                                          # apply everything pending
poindexter migrate up --to 20260510_065631                     # stop at that timestamp (everything ≤ it)
poindexter migrate up --to 20260510_065631_drop_experiments_tables.py  # also accepted
poindexter migrate up --json
```

`--to` accepts either a timestamp prefix (`YYYYMMDD_HHMMSS`) or a full
filename. The summary line at the end reads
`applied N, skipped M, failed K, pending P`.

### `migrate down [--to NAME] [--all] [--yes] [--json]`

Roll back applied migrations. Migrations are rolled back in reverse
order. Without flags, only the most recent applied migration is
rolled back.

```bash theme={null}
poindexter migrate down                                            # roll back only the latest
poindexter migrate down --to 20260510_044707                       # roll back everything > that timestamp
poindexter migrate down --all                                      # roll back EVERYTHING (asks first)
poindexter migrate down --all --yes                                # skip the confirmation
poindexter migrate down --json
```

A migration's rollback path is whichever of these the migration
module exposes (in priority order):

* `async def down(pool)` — pool-based, the modern convention
* `async def rollback_migration(conn)` — connection-based, the older
  convention

If a migration defines neither, `migrate down` skips it and exits
non-zero with a `no down()/rollback_migration() defined — skipped`
notice. The `schema_migrations` row is left intact in that case so
nothing silently disappears from the tracker.

`--all` and `--to` are mutually exclusive. `--all` prompts for
confirmation unless `--yes` is set or `--json` is set (JSON mode
suppresses the interactive prompt).

***

## Environment variables

The CLI respects a small set of env vars for non-interactive use:

| Variable             | Purpose                                                           |
| -------------------- | ----------------------------------------------------------------- |
| `DATABASE_URL`       | PostgreSQL DSN. Overrides bootstrap.toml if set.                  |
| `POINDEXTER_API_URL` | Base URL for the worker API (default `http://localhost:8002`).    |
| `POINDEXTER_TOKEN`   | Bearer token for the worker API. Overrides bootstrap.toml if set. |

Everything else comes from `~/.poindexter/bootstrap.toml` and the
`app_settings` DB table.

***

## JSON output mode

Every list/search/get subcommand supports `--json-output`. Use it for
piping into `jq` or building shell automation:

```bash theme={null}
poindexter tasks list --status rejected --json-output | jq '.[] | {id, score: .quality_score, error: .error_message}'
```

Pretty-printed tables are the default for humans. JSON is for scripts.
