Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# AGENTS.md

## Project Overview

ChittyCommand is a unified life management and action dashboard for the ChittyOS ecosystem. It ingests data from 15+ financial, legal, and administrative sources, scores urgency with AI, recommends actions, and executes them via APIs, email, or browser automation.

**Repo:** `CHITTYOS/chittycommand`
**Deploy:** Cloudflare Workers at `command.chitty.cc`
**Stack:** Hono TypeScript, React + Tailwind, Neon PostgreSQL (via Hyperdrive), Cloudflare R2/KV
**Canonical URI:** `chittycanon://core/services/chittycommand` | Tier 5

## Common Commands

```bash
npm run dev # Start Hono dev server (wrangler dev)
npm run deploy # Deploy to Cloudflare Workers
npm run ui:dev # Start React frontend dev server (localhost:5173)
npm run ui:build # Build frontend for Pages
npm run db:generate # Generate Drizzle migrations
npm run db:migrate # Run Drizzle migrations
```

Secrets are managed via wrangler (never hardcode):
```bash
wrangler secret put PLAID_CLIENT_ID
wrangler secret put PLAID_SECRET
wrangler secret put DATABASE_URL
```

## Architecture

Single Cloudflare Worker (`chittycommand`) serving API + cron. Frontend is a separate React SPA at `app.command.chitty.cc` (Cloudflare Pages).

### Data Sources

**Via ChittyFinance (auto-sync):** Mercury, Wave Accounting, Stripe, Plaid
**Direct API:** ChittyBooks, ChittyAssets, ChittyCharge, ChittyLedger
**Via ChittyScrape (bridge):** Mr. Cooper mortgage, Cook County property tax, Court docket
**Email Parse:** ComEd, Peoples Gas, Xfinity, Citi, Home Depot, Lowe's
**Manual:** IRS quarterly, HOA fees, Personal loans
**Historical Only:** DoorLoop (sunset, data archived)

### Auth Flow

Three auth layers in `src/middleware/auth.ts`:
1. **`authMiddleware`** (`/api/*`) — KV token lookup, then ChittyAuth fallback
2. **`bridgeAuthMiddleware`** (`/api/bridge/*`) — Service token OR user token
3. **`mcpAuthMiddleware`** (`/mcp/*`) — ChittyAuth token validation first, shared KV service token fallback (bypassed in dev)

### Cron Schedule

Defined in `wrangler.toml`, dispatched via `src/lib/cron.ts`:
- `0 12 * * *` — Daily 6 AM CT: Plaid + ChittyFinance sync
- `0 13 * * *` — Daily 7 AM CT: Court docket check
- `0 14 * * 1` — Weekly Mon 8 AM CT: Utility scrapers
- `0 15 1 * *` — Monthly 1st 9 AM CT: Mortgage, property tax

### Database

Neon PostgreSQL via Hyperdrive binding. All tables prefixed `cc_`. Schema in `src/db/schema.ts`, SQL migrations in `migrations/` (0001-0007).

### Action Execution

Three modes:
1. **API** — Mercury transfers, Stripe payments via bridge routes
2. **Codex in Chrome** — Browser automation for portals without APIs
3. **Email** — Dispute letters, follow-ups via Cloudflare Email Workers

## Key Files

- `src/index.ts` — Hono entry point, route mounting, health/status endpoints
- `src/middleware/auth.ts` — Auth middleware (user, bridge, MCP)
- `src/lib/cron.ts` — Cron sync orchestrator (all data sources)
- `src/lib/integrations.ts` — Service clients (Mercury, Plaid, ChittyScrape, etc.)
- `src/lib/urgency.ts` — Deterministic urgency scoring engine
- `src/lib/validators.ts` — Zod schemas for request validation
- `src/routes/bridge.ts` — Inter-service bridge (scrape, ledger, finance, Plaid)
- `src/routes/mcp.ts` — MCP server for Codex integration (28 tools)
- `src/routes/meta.ts` — Public canon/schema/beacon + authenticated whoami
- `src/routes/connect.ts` — ChittyConnect discovery proxy (rate-limited)
- `src/routes/ledger.ts` — ChittyLedger evidence/custody passthrough
- `src/routes/context.ts` — Persona/context management (user + global)
- `src/routes/auth.ts` — Login/verify flows
- `src/routes/token-management.ts` — Admin token CRUD
- `src/routes/dashboard.ts` — Dashboard summary with urgency scoring
- `src/db/schema.ts` — Drizzle schema for all cc_* tables
- `migrations/` — SQL migration files (0001-0007)
- `ui/` — React frontend (Vite + Tailwind)

## Security

- Credentials via 1Password (`op run`) — never expose in terminal output
- Secrets via `wrangler secret put` — never in `[vars]`
- R2 for document storage (zero egress)
- CORS restricted to `app.command.chitty.cc`, `command.mychitty.com`, `chittycommand-ui.pages.dev`, `localhost:5173`
- Service tokens stored in KV: `bridge:service_token`, `mcp:service_token`, `scrape:service_token`

## Codex Setup

Use Codex (or Codex Desktop with MCP support) to connect directly to the `/mcp` HTTP server.

- Server URL: `https://command.chitty.cc/mcp`
- Transport: HTTP (JSON‑RPC 2.0, streamable HTTP)
- Auth (production): `Authorization: Bearer <ChittyAuth token>` (preferred) or legacy `Authorization: Bearer <KV mcp:service_token>`
- Auth (dev): If `ENVIRONMENT != 'production'`, auth is bypassed

Example client-side MCP configuration (conceptual):

```
{
"mcpServers": {
"chittycommand": {
"type": "http",
"url": "https://command.chitty.cc/mcp",
"headers": { "Authorization": "Bearer <your_mcp_service_token>" }
}
}
}
```

The server exposes 28 tools across 8 domains:

**Core meta** — `get_canon_info`, `get_registry_status`, `get_schema_refs`, `whoami`, `get_context_summary`
**Financial** — `query_obligations`, `query_accounts`, `query_disputes`, `get_recommendations`, `get_cash_position`, `get_cashflow_projections`
**Ledger** — `ledger_stats`, `ledger_get_evidence`, `ledger_record_custody`, `ledger_facts`, `ledger_contradictions`, `ledger_create_case_for_dispute`, `ledger_link_case_for_dispute`
**Connect** — `connect_discover`
**ChittyChat** — `chittychat_list_projects`, `chittychat_list_tasks`, `chittychat_get_task`
**Schema** — `schema_list_types`, `schema_get`, `schema_validate`, `schema_drift`
**Cert** — `cert_verify`
**Register** — `register_requirements`

Tools return structured JSON using MCP `content: [{ type: "json", json: ... }]` where applicable, enabling Codex to consume results without text parsing.
22 changes: 22 additions & 0 deletions docs/plans/2026-03-05-adversarial-review-restated.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,25 @@
- Added reusable confirmation dialog.
- Added Dashboard pay confirmation and feedback toasts.
- Added Action Queue auto-triage on first empty load and decision feedback toasts.

## Update (2026-03-08)

- P1 double-write fix implemented:
- `POST /api/queue/:id/decide` no longer mutates obligation status.
- Action Queue approval now explicitly calls `/api/obligations/:id/pay` for payment-type recommendations.
- Queue decision state and payment execution are now separated to avoid duplicate writes.

- P2 dispute lifecycle foundation implemented:
- Added `cc_disputes.stage` via migration + schema/validators.
- Added stage/status consistency rules in dispute create/update APIs.
- Replaced synthetic dispute progress with DB-backed stage progress in UI.
- Added dispute creation form in frontend.
- Added direct document upload from dispute context (`linked_dispute_id` flow).

- P3 flow-connection updates implemented:
- Legal deadlines now include linked dispute metadata (via `case_ref` to dispute metadata case ids).
- Added `GET /api/disputes/:id/deadlines` for dispute-context deadline visibility.
- Legal page now deep-links into Disputes using `?expand={dispute_id}`.
- Disputes page supports `Deadlines` panel and URL-based auto-expand.
- Added `POST /api/payment-plan/:id/enqueue` to convert plan schedule items into queue recommendations.
- Payment Planner UI now supports `Send to Queue` with success/error feedback.
18 changes: 18 additions & 0 deletions migrations/0010_dispute_stages.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Add explicit lifecycle stage for disputes.
ALTER TABLE cc_disputes
ADD COLUMN IF NOT EXISTS stage TEXT DEFAULT 'filed';

-- Backfill existing rows.
UPDATE cc_disputes
SET stage = CASE
WHEN status IN ('resolved', 'dismissed') THEN 'resolved'
ELSE 'filed'
END
WHERE stage IS NULL;

ALTER TABLE cc_disputes
ALTER COLUMN stage SET DEFAULT 'filed';

ALTER TABLE cc_disputes
ALTER COLUMN stage SET NOT NULL;

76 changes: 72 additions & 4 deletions src/routes/disputes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ledgerClient } from '../lib/integrations';
import { createDisputeSchema, updateDisputeSchema, createCorrespondenceSchema, disputeQuerySchema } from '../lib/validators';

export const disputeRoutes = new Hono<{ Bindings: Env }>();
const TERMINAL_STATUSES = new Set(['resolved', 'dismissed']);

// List disputes
disputeRoutes.get('/', async (c) => {
Expand Down Expand Up @@ -34,16 +35,49 @@ disputeRoutes.get('/:id', async (c) => {
return c.json({ ...dispute, correspondence, documents });
});

// Get deadlines linked to this dispute (via ledger_case_id/case_ref metadata)
disputeRoutes.get('/:id/deadlines', async (c) => {
const sql = getDb(c.env);
const id = c.req.param('id');

const [dispute] = await sql`
SELECT id, title, COALESCE(metadata->>'ledger_case_id', metadata->>'case_ref') AS case_ref
FROM cc_disputes
WHERE id = ${id}
`;
if (!dispute) return c.json({ error: 'Dispute not found' }, 404);

const caseRef = (dispute as { case_ref: string | null }).case_ref;
if (!caseRef) return c.json([]);

const deadlines = await sql`
SELECT *
FROM cc_legal_deadlines
WHERE case_ref = ${caseRef}
AND status != 'completed'
ORDER BY deadline_date ASC
`;
return c.json(deadlines);
});

// Create dispute
disputeRoutes.post('/', async (c) => {
const raw = await c.req.json();
const result = createDisputeSchema.safeParse(raw);
if (!result.success) return c.json({ error: 'Validation failed', issues: result.error.issues }, 400);
const body = result.data;
const stage = body.stage || 'filed';
const status = body.status || 'open';
if (stage !== 'resolved' && status !== 'open') {
return c.json({ error: 'Non-terminal stages must keep status=open' }, 400);
}
if (stage === 'resolved' && status === 'open') {
return c.json({ error: 'stage=resolved requires status=resolved or status=dismissed' }, 400);
}
const sql = getDb(c.env);
const [dispute] = await sql`
INSERT INTO cc_disputes (title, counterparty, dispute_type, amount_claimed, amount_at_stake, status, priority, description, next_action, next_action_date, resolution_target, metadata)
VALUES (${body.title}, ${body.counterparty}, ${body.dispute_type}, ${body.amount_claimed || null}, ${body.amount_at_stake || null}, ${body.status || 'open'}, ${body.priority || 5}, ${body.description || null}, ${body.next_action || null}, ${body.next_action_date || null}, ${body.resolution_target || null}, ${JSON.stringify(body.metadata || {})})
INSERT INTO cc_disputes (title, counterparty, dispute_type, amount_claimed, amount_at_stake, stage, status, priority, description, next_action, next_action_date, resolution_target, metadata)
VALUES (${body.title}, ${body.counterparty}, ${body.dispute_type}, ${body.amount_claimed || null}, ${body.amount_at_stake || null}, ${stage}, ${status}, ${body.priority || 5}, ${body.description || null}, ${body.next_action || null}, ${body.next_action_date || null}, ${body.resolution_target || null}, ${JSON.stringify(body.metadata || {})})
RETURNING *
`;
// Fire-and-forget: push to ChittyLedger as a case
Expand Down Expand Up @@ -72,17 +106,51 @@ disputeRoutes.patch('/:id', async (c) => {
if (!result.success) return c.json({ error: 'Validation failed', issues: result.error.issues }, 400);
const body = result.data;
const sql = getDb(c.env);
const [existing] = await sql`SELECT * FROM cc_disputes WHERE id = ${id}`;
if (!existing) return c.json({ error: 'Dispute not found' }, 404);

const nextStage = (body.stage ?? existing.stage ?? 'filed') as string;
const nextStatus = (body.status ?? existing.status ?? 'open') as string;
if (nextStage !== 'resolved' && nextStatus !== 'open') {
return c.json({ error: 'Non-terminal stages must keep status=open' }, 400);
}
if (nextStage === 'resolved' && !TERMINAL_STATUSES.has(nextStatus)) {
return c.json({ error: 'stage=resolved requires status=resolved or status=dismissed' }, 400);
}

const [dispute] = await sql`
UPDATE cc_disputes SET
status = COALESCE(${body.status ?? null}, status),
title = COALESCE(${body.title ?? null}, title),
counterparty = COALESCE(${body.counterparty ?? null}, counterparty),
dispute_type = COALESCE(${body.dispute_type ?? null}, dispute_type),
amount_claimed = COALESCE(${body.amount_claimed ?? null}, amount_claimed),
amount_at_stake = COALESCE(${body.amount_at_stake ?? null}, amount_at_stake),
stage = ${nextStage},
status = ${nextStatus},
priority = COALESCE(${body.priority ?? null}, priority),
next_action = COALESCE(${body.next_action ?? null}, next_action),
next_action_date = COALESCE(${body.next_action_date ?? null}, next_action_date),
description = COALESCE(${body.description ?? null}, description),
resolution_target = COALESCE(${body.resolution_target ?? null}, resolution_target),
metadata = COALESCE(${body.metadata ? JSON.stringify(body.metadata) : null}::jsonb, metadata),
updated_at = NOW()
WHERE id = ${id} RETURNING *
`;
if (!dispute) return c.json({ error: 'Dispute not found' }, 404);

if (body.stage || body.status) {
await sql`
INSERT INTO cc_actions_log (action_type, target_type, target_id, description, request_payload, status)
VALUES (
'dispute_lifecycle_update',
'dispute',
${id},
${`Lifecycle update: stage=${nextStage}, status=${nextStatus}`},
${JSON.stringify({ before: { stage: existing.stage, status: existing.status }, after: { stage: nextStage, status: nextStatus } })},
'completed'
)
`;
}

return c.json(dispute);
});

Expand Down
12 changes: 10 additions & 2 deletions src/routes/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getDb } from '../lib/db';
import { ledgerClient } from '../lib/integrations';

export const documentRoutes = new Hono<{ Bindings: Env }>();
const UUID_V4ISH = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

documentRoutes.get('/', async (c) => {
const sql = getDb(c.env);
Expand All @@ -22,7 +23,14 @@ const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB
documentRoutes.post('/upload', async (c) => {
const formData = await c.req.formData();
const file = formData.get('file') as unknown as File | null;
const linkedDisputeRaw = formData.get('linked_dispute_id');
const linkedDisputeId = typeof linkedDisputeRaw === 'string' && linkedDisputeRaw.trim().length > 0
? linkedDisputeRaw.trim()
: null;
if (!file || typeof file === 'string') return c.json({ error: 'No file provided' }, 400);
if (linkedDisputeId && !UUID_V4ISH.test(linkedDisputeId)) {
return c.json({ error: 'Invalid linked_dispute_id' }, 400);
}

if (!ALLOWED_TYPES.has(file.type)) {
return c.json({ error: 'Unsupported file type', allowed: [...ALLOWED_TYPES] }, 400);
Expand All @@ -43,8 +51,8 @@ documentRoutes.post('/upload', async (c) => {

// Create DB record
const [doc] = await sql`
INSERT INTO cc_documents (doc_type, source, filename, r2_key, processing_status)
VALUES ('upload', 'manual', ${safeName}, ${r2Key}, 'pending')
INSERT INTO cc_documents (doc_type, source, filename, r2_key, linked_dispute_id, processing_status)
VALUES ('upload', 'manual', ${safeName}, ${r2Key}, ${linkedDisputeId}, 'pending')
RETURNING *
`;

Expand Down
18 changes: 16 additions & 2 deletions src/routes/legal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@ export const legalRoutes = new Hono<{ Bindings: Env }>();
legalRoutes.get('/', async (c) => {
const sql = getDb(c.env);
const deadlines = await sql`
SELECT * FROM cc_legal_deadlines
WHERE status != 'completed'
SELECT
l.*,
d.id AS dispute_id,
d.title AS dispute_title
FROM cc_legal_deadlines l
LEFT JOIN LATERAL (
SELECT id, title, status, priority, created_at
FROM cc_disputes d
WHERE l.case_ref = COALESCE(d.metadata->>'ledger_case_id', d.metadata->>'case_ref')
ORDER BY
CASE WHEN d.status = 'open' THEN 0 ELSE 1 END,
d.priority ASC NULLS LAST,
d.created_at DESC
LIMIT 1
) d ON TRUE
WHERE l.status != 'completed'
ORDER BY deadline_date ASC
`;
return c.json(deadlines);
Expand Down
Loading
Loading