Skip to content

feat(chisel): Phase A — framework + 4 tools + 1 workflow migration - #211

Open
jpb33333 wants to merge 37 commits into
devfrom
feat/pebble-chisel-scoping
Open

feat(chisel): Phase A — framework + 4 tools + 1 workflow migration#211
jpb33333 wants to merge 37 commits into
devfrom
feat/pebble-chisel-scoping

Conversation

@jpb33333

Copy link
Copy Markdown
Collaborator

Summary

Lands Phase A of the Chisel plan: the tool/workflow authoring framework, plus migration of all four built-in tools and the weekly_pipeline_review workflow off the legacy side-effect-registration pattern.

  • Framework at pebble/chisel/: manifest models, Pydantic→strict-JSON-schema (P1), handler adapter that eliminates per-tool ToolResult boilerplate (P2), autoload with per-unit error isolation (P4), snapshot-per-request reload safety (P5), RBAC stub (§11.9), AST lints, argparse CLI with scaffold subcommand (P10).
  • Migrations: request_human_review, generate_chart, get_record, search_crm, and aggregate_pipeline_views now live under pebble/chisel/tools/. weekly_pipeline_review lives under pebble/chisel/workflows/ with has_custom_plan=true + build_plan.py.
  • Cutover: pebble/handlers/streaming.py calls chisel.autoload() at module load (replaces from ..orchestrator import builtin_tools + from .. import workflows side-effect imports). pebble/router.py reads the slash table from chisel.slash_to_intent(). Legacy modules deleted — no coexistence per plan §11.8.
  • Forward-compat: ToolResult gains optional tool_version: str | None, filled by the adapter from the manifest. Sprint-11 scratchpad will record it.

Commits (in landing order)

  1. 424b72b docs: chisel plan + scoping verification pass — locked for Phase A
  2. e70fae9 feat: scaffold framework — no-op autoload, manifest models, schema strictness
  3. 3bdcbcb test: Phase A.1 framework unit tests — 27 cases
  4. 98461e9 feat: migrate request_human_review to manifest
  5. d1118c5 feat: migrate generate_chart to manifest
  6. cae2ffd feat: migrate get_record to manifest
  7. 60ec903 feat: migrate search_crm to manifest
  8. 1dfd4aa feat: migrate weekly_pipeline_review workflow + cutover
  9. 8927199 docs: pebble/README authoring section

Test plan

  • Framework unit tests (29 cases) — manifest validation, schema strictness, handler adapter, autoload isolation, RBAC, lints, snapshot
  • Per-tool tests (28 cases) — happy path + validation + transport errors
  • Full pebble suite green: 647 passed (was 702; legacy test_orchestrator_builtin_tools.py + test_workflows_weekly_pipeline_review.py deleted, replaced by chisel-side equivalents)
  • Cross-test isolation: conftest.py autouse fixture re-runs chisel.autoload() so tmp-path framework tests don't pollute router/streaming tests
  • Smoke /pipeline end-to-end in a running Pebble instance (defer to reviewer or pre-merge)
  • Eyeball planner tool-list still includes all 5 tools with strict schemas (verified programmatically; manual confirm welcome)

What's next

  • Phase B — eval harness with canonical queries (1 wk)
  • Phase C — GUI builder under financial_forecasting/frontend-v2/src/pages/chisel/ (3–4 wks)

🤖 Generated with Claude Code

JP and others added 30 commits May 6, 2026 19:03
…LED)

First slice of Pebble 1.0 plan §0.5 — runtime flag that halts
Pebble's internal-key write paths during incidents without a redeploy.
Reads via the same internal key and all JWT-authenticated user
requests are unaffected.

What changed:
- auth.py: new `_pebble_writes_disabled()` reads env on every call
  so flipping the flag doesn't require a deploy. Check fires after
  the hmac key validation succeeds and only on POST/PUT/PATCH/DELETE.
  GET/HEAD/OPTIONS via internal key still work, so research/lookup
  paths keep functioning during a write-side incident. Wrong-key
  callers fall through to require_auth as before — the kill switch
  doesn't leak its own state to unauthenticated traffic.
- tests/test_pebble_kill_switch.py: 23 tests covering 5 invariants
  (switch off / writes blocked / reads pass / JWT unaffected /
  wrong-key falls through), parameterized across truthy/falsy env
  values and all four write methods. All passing.

Plan ref: tasks/pebble-overhaul-plan.md §0.5
Adversary ref: tasks/pebble-adversary-security.md S2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independently-grounded specs (backend, ux, security) at
tasks/pebble-search-spec-{backend,ux,security}.md each ~3 pages.
This file is the build order — picks the load-bearing decisions,
links to the deep specs for argued tradeoffs.

Locked decisions:
  * Backend = PG FTS + pgvector overlay; one denormalized
    bedrock.search_doc table; queue-drain indexer; pre-filter
    permission via accessible-id subquery.
  * X-Originating-User mandatory on every internal-key write +
    search; Pebble becomes a delegated principal not a god
    principal.
  * Two audit tables: pebble_write_audit (writes) + search_audit
    (queries). 90d hot, 13mo Parquet archive. Admin reveals of
    query_text logged to a separate table that survives RTBF.
  * GlobalSearch.tsx: dual-mode (Find | Ask), permanent footer
    chip, ?/ prefix triggers, /pebble sidebar entry.
  * Multi-tenant org_id from day 1 as outermost predicate.
  * Trace propagation FE → Bedrock → Pebble → DB.
  * Circuit breakers per backend with explicit fallback order.
  * SLO p95 200ms Find / 3s first-token Ask.
  * No SaaS data egress for v1.0 — all in-house.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0.1 + 0.7 of the Pebble 1.0 plan.

bedrock.pebble_write_audit captures every internal-key write with:
  * request_id (UNIQUE — doubles as 24h replay defense)
  * route + method + http status + payload_hash + payload
  * sf_object_type + sf_object_id (subject)
  * originating_user_email (the human; mandatory upstream via
    auth.py:require_auth_or_internal — next commit)
  * service_user (e.g. "service:pebble")
  * side_effects JSONB (auto-award, activity log, future fanout)
  * latency_ms + org_id (multi-tenant outer guard)

Three independent adversarial reviews flagged the un-attributed
"service:pebble" pattern as a 1.0 blocker; this table closes it.

bedrock.search_audit_access_log captures admins reading other
users' search history with query_text revealed. Append-only;
lives separately from search_audit so RTBF/DSAR purges of
search_audit cannot also erase the accountability trail.

Both grants restricted to bedrock_user; pebble_write_audit has
no UPDATE grant (audit immutability for forensics) and no
DELETE grant (retention job uses a different role, added when
the 90d cleanup script lands).

Migration is idempotent. Apply as bedrock owner.

Plan ref: tasks/pebble-search-spec.md decisions §3, §4
Adversary refs:
  pebble-adversary-security.md H3, M3, S2, S3
  pebble-adversary-architecture.md #6
  pebble-adversary-ux.md #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0.6. Refuses to start when production-shaped Pebble has a
misconfigured service-to-service bridge to Bedrock.

Failure modes this catches:
  * BEDROCK_API_URL unset → defaults to http://localhost:8000 in
    crm_bridge.py:14. Every Pebble write silently routes to
    localhost in prod and bypasses TLS.
  * BEDROCK_INTERNAL_API_KEY unset → crm_bridge.py:27-28 sends
    requests with no internal-key header. Bedrock falls through
    to JWT auth; every Pebble write fails 401.

Production detection mirrors auth.py:25-26 (FRONTEND_URL starts
with https://) so dev/staging/prod classify identically across
the two FastAPI processes. PEBBLE_ENV=production overrides the
FRONTEND_URL check for headless cron deployments.

Whitespace-only API keys count as missing — defensive against
.env files with `BEDROCK_INTERNAL_API_KEY="   "`.

11 tests covering: dev path no-op, prod path raises on each
failure mode, PEBBLE_ENV override, case-insensitive prod detect,
whitespace-only-key rejection.

Plan ref: tasks/pebble-search-spec.md §6, §10
Adversary ref: pebble-adversary-security.md S4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0.2 + 0.4 of the Pebble 1.0 plan. Pebble becomes a
delegated principal, not a god principal.

What changed:
  * require_auth_or_internal now requires three headers when the
    internal-key path is taken:
      - X-Internal-Key (existing) — service-to-service auth
      - X-Originating-User (new) — the human whose session
        triggered this call. RFC 5321-shaped email; rejected
        if missing, malformed, or > 254 chars.
      - X-Request-Id (new) — UUID for replay defense via
        UNIQUE(request_id) on bedrock.pebble_write_audit.
  * Synthetic user dict gains originating_user_email, request_id,
    and a scopes tuple. X-Pebble-Scopes header (optional)
    overrides the default ("*",) full grant — future tightening
    will narrow scopes per-call instead of carrying full grant.
  * Wrong-key callers fall through to require_auth WITHOUT
    surfacing the new contract. We don't leak the kill-switch
    state or the originating-user requirement to unauthenticated
    traffic.
  * Validation kept lightweight on the auth path — durable
    membership check is at the audit-row INSERT (FK lookup vs
    org_users) per the security spec.

Why mandatory on reads too: Pebble must always carry its
delegated principal so research/lookup paths get audited the
same way as writes. Silent reads on behalf of a phantom user
are an audit hole.

34 new tests in test_pebble_internal_auth.py covering 8
invariants. Existing kill-switch tests updated to satisfy the
new contract; all 23 still pass.

Plan ref: tasks/pebble-search-spec.md decision §3
Adversary refs:
  pebble-adversary-security.md H1, H3, M3, S1
  pebble-adversary-architecture.md #6
  pebble-adversary-ux.md #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cache

Phase 0.9. Single source of truth for SF Opportunity stage
values, replacing the eight independent locations that today
carry stage strings literally:
  * models.py:OpportunityStage enum
  * frontend-v2/src/lib/{stages,funnelStages}.ts
  * frontend-v2/src/components/StageProgression.tsx
  * frontend-v2/src/pages/Cleanup.tsx
  * services/crm_parser.py
  * routes/opportunities_extra.py:53
  * services/awards_service.ELIGIBLE_STAGES_BY_RECORD_TYPE

Every SF picklist rename (commit 58c360e renamed three at once)
required hand-edits across all eight. This module collapses
them into one fetch.

Architecture:
  * services/sf_stages.py — async read API (`get_stages`,
    `get_entry_stage`) + sync bucket predicates
    (`is_revenue_earning`, `is_open`, `is_closed`, `is_lost`)
  * 24h read-through cache layered over a process-local 5min
    in-memory cache layered over the static fallback table
  * Static fallback mirrors models.OpportunityStage as of
    2026-05-06 — last-line-of-defense for fresh deploys before
    the picklist refresh job has run

Buckets are frozenset[str] preserving the F1 PR #134 contract:
reporting semantics LAYER ON TOP of stages, never replace them.
Code that says `if stage in REVENUE_EARNING_STAGES:` keeps
working when SF adds a new stage that the refresh job
classifies into the bucket — no code change needed.

bedrock.sf_picklist_cache migration:
  * Composite key (org_id, sf_object, field_name, record_type, value)
  * Per-row buckets TEXT[] for reporting classification
  * GIN index on buckets, partial index on stale rows
  * 24h refresh_after column drives the cache TTL
  * Multi-tenant org_id outer guard from day 1

26 tests covering static fallback / cache hit / cache clear /
DB preferred / DB error / entry stage / bucket predicates /
bucket invariants (revenue ⊆ closed, lost ⊆ closed, open ∩
closed = ∅) / frozenset immutability.

This file does NOT yet replace the eight call-sites. That
migration lands in Layer 1, after the picklist refresh job
exists. Callers swap incrementally.

Plan ref: tasks/pebble-search-spec.md §0.9
Memory refs: feedback_sf_stages_sacred, project_stage_schema_drift

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 1.1 + 1.2 + 1.3 of the Pebble 1.0 search system. The
durable schema for cross-entity, permission-aware,
audit-logged search.

bedrock.search_doc — denormalized one-row-per-entity index:
  * tsvector + GIN (lexical match)
  * pg_trgm + GIN on search_text (typo tolerance: "ed millr" →
    "Ed Miller")
  * pgvector halfvec(768) + HNSW reserved for v1.1 semantic
    overlay (column defined now so backfill never ALTER's a
    200k-row hot table)
  * Permission columns FK-shaped (owner_sf_id, owner_email,
    account_sf_id, visibility) — pre-filter via accessible-id
    subquery, never post-filter, never denormalize ACL into
    rows
  * Soft-delete via partial GIN — tombstones in heap, never in
    posting list
  * search_doc_compose_vector() trigger sets relevance weights
    in one place (A: title, B: subtitle, C: search_text)
  * Multi-tenant org_id outer guard from day 1

bedrock.search_index_queue — durable hand-off:
  * UNIQUE(entity_type, entity_id, op) coalesces dup enqueues;
    ON CONFLICT bumps enqueued_at so the worker still drains
    in FIFO without missing any
  * Generic enqueue_search_index() trigger function takes
    entity_type via TG_ARGV[0]; source tables get one CREATE
    TRIGGER apiece in subsequent migrations
  * pg_notify('bedrock_search_index_queue', et||':'||eid) wakes
    the asyncio worker (drain pattern: FOR UPDATE SKIP LOCKED
    LIMIT 100)

bedrock.search_audit — every query (Find / Ask / past_research):
  * query_id correlates query → results → click; request_id is
    Phase 0.7 idempotency key with UNIQUE replay defense
  * Two attribution columns: user_email (bearer; e.g.
    pebble@internal) + originating_user_email (delegated human)
  * query_text stored FULL at v1.0; query_text_hash is the
    default dashboard join key (no PII leak); raw text reads
    gated on manage_users_roles and self-audited via
    search_audit_access_log
  * Per-backend latency breakdown (perm_resolution_ms,
    backend_latency_ms) for SLO drill-down
  * result_count vs result_count_redacted exposes the
    permission-redaction leak surface
  * Cost columns mirror pebble_daily_usage shape so budget
    enforcement reads from one source

All three migrations idempotent. Apply as bedrock owner.

Plan refs:
  tasks/pebble-search-spec-backend.md §2-§3
  tasks/pebble-search-spec-security.md §2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 1.8 of the Pebble 1.0 search system. The cross-entity Find
endpoint that GlobalSearch.tsx Find mode and Pebble's Ask-mode
tool calls both query against. Replaces the SOSL passthrough at
routes/salesforce_search.py incrementally (dual-read in Phase 2).

services/search_service.py:
  * SearchPrincipal — the effective principal whose permissions
    filter the query. Service callers (Pebble, is_service=True)
    resolve to their X-Originating-User; Pebble is delegated, not
    god-principal.
  * resolve_principal — reads bedrock.org_users +
    permission_profiles to compose the principal. Non-existent
    user → maximally restrictive (no rows visible).
  * _compose_permission_predicate — the OR-chain that the
    backend spec §4 argues for: pre-filter via accessible-id
    subquery, never post-filter, never denormalize ACL into rows.
    Multi-tenant org_id is the OUTERMOST predicate, always.
  * search() — composes the WITH-CTE query, applies recency decay
    (ts_rank_cd × exp(-Δs / 30-day-half-life)), returns
    rank-ordered SearchHits.
  * Strict input validation: type list rejects unknown
    entity_type; limit clamping to MAX_LIMIT=100; empty query
    short-circuits without DB hit.

routes/search.py:
  * GET /api/search — happy path returns flat + grouped views
    so the frontend picks shape it wants.
  * POST /api/search/click — click attribution for ranking
    quality. Fenced on user_email match (one user can't poison
    another's history).
  * Permission gate: check_permission_or_internal("view_opportunities")
    via a stable module-level reference (require_search_perm)
    so tests can override.
  * Audit row INSERT runs as a BackgroundTask — never blocks the
    response. ON CONFLICT (request_id) DO NOTHING idempotency.
    Audit failure is logged, never propagates to the user.
  * X-Request-Id used as audit row request_id. Malformed header
    → mint UUID, log warning. Empty header → mint UUID.
  * Long queries truncated at 256 chars in audit_text (defense
    against bypass of the 256 max_length validator).

56 tests across both files:
  * test_search_service: 35 — covers the OR-chain composition
    cell-by-cell (admin path, view-all path, restricted path,
    each branch of the OR), org_id always present invariant,
    type validation, limit clamping, service-spoof protection,
    query_text_hash stability + case-insensitivity, e2e against
    a mocked connection.
  * test_search_route: 21 — happy path, empty results, unknown
    entity_type → 400, missing/empty/oversized inputs → 422,
    audit BackgroundTask invoked, audit failure swallowed,
    click endpoint validation, header parsing helpers.

Plan refs:
  tasks/pebble-search-spec-backend.md §2-§5
  tasks/pebble-search-spec-security.md §1, §2
  tasks/pebble-search-spec.md decisions §2, §4, §6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 1.6 of the Pebble 1.0 search system. Drains
bedrock.search_index_queue and upserts the denormalized
projection into bedrock.search_doc.

services/search_indexer.py:
  * Composer registry — one async fn per entity_type that reads
    the source row and produces a SearchDocRow projection.
    Composers can return None to signal "row is gone or
    shouldn't be indexed"; indexer soft-deletes in search_doc.
  * Built-in composers for bedrock_project, bedrock_saved_view,
    pebble_profile (sourced from bedrock.pebble_research_sessions
    where status='completed' — has explicit name/org/tier columns
    vs pebble_profiles' opaque profile_json).
  * drain_once(pool) — claims a batch with FOR UPDATE SKIP
    LOCKED, dispatches per row, removes successes, bumps
    attempt_count on errors. Single transaction per row so a
    poison pill doesn't block the queue head.
  * MAX_ATTEMPT_COUNT=5 hard cap; attempt-exhausted rows stay
    in queue with last_error populated for the periodic
    reconciliation job.
  * No-composer rows leave the queue intact (no attempt bump) —
    a future worker with the composer registered processes
    them without restart. This is how the bedrock_award trigger
    (wired below) gracefully waits for its composer.
  * run_worker(pool, stop_event) — long-running drain. LISTEN
    on bedrock_search_index_queue + 2s polling fallback.
    Exponential backoff to 60s on consecutive failures; never
    crashes the FastAPI process.
  * backfill(pool, entity_type) — one-shot reindex from source.
    Used after schema changes or for first-deploy population.

db/migrations/2026-05-06-search-source-triggers.sql:
  * AFTER INSERT/UPDATE/DELETE triggers on bedrock.project,
    bedrock.saved_view, bedrock.award.
  * Conditional trigger on bedrock.pebble_research_sessions
    (only fires when status='completed') + a separate DELETE
    trigger (NEW is null in AFTER DELETE so WHEN can't reference
    NEW.status).
  * bedrock.award trigger ships ahead of its composer — by the
    time the composer lands (depends on Layer 1.4 SF mirrors),
    the queue has every award row already enqueued, no
    separate backfill needed.

19 tests in test_search_indexer.py:
  * Registry: register/get/clear, default registrations, overwrites.
  * Composer correctness for all 3 built-ins (happy / deleted /
    missing / wrong-status / invalid JSON).
  * drain_once: upsert + dequeue, soft-delete on op='delete',
    no-composer leaves queue intact, composer error bumps
    attempt_count + records last_error, MAX_ATTEMPT_COUNT
    enforced in SELECT.
  * backfill: enqueues all source rows, rejects unregistered
    entity_type.

This module is callable + testable in isolation. Wire-up to
main.py lifespan is a separate change.

Plan refs: tasks/pebble-search-spec-backend.md §3
           tasks/pebble-search-spec.md Layer 1.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the search read path and indexer worker into the
production FastAPI process.

main.py changes:
  * Import routes/search.py and mount /api/search +
    /api/search/click endpoints.
  * In startup_event: read the asyncpg pool, create a
    stop_event, kick off run_search_indexer_worker as a
    long-lived asyncio task. Stop_event + task stored on
    _services dict so shutdown can stop them cleanly.
  * In shutdown_event: set stop_event, then asyncio.wait_for
    the task with a 5-second drain timeout. Cancel-hard if it
    doesn't shut down — production restarts shouldn't be
    blocked by a stuck indexer.
  * Indexer failure is non-critical at startup: search remains
    available (existing search_doc rows still queryable), it
    just won't reflect new writes until restart. Logged
    loudly via logger.exception.

main.py smoke-tested: imports clean, /api/search and
/api/search/click both registered.

Plan refs: tasks/pebble-search-spec.md Layer 1.6, 1.8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 3.1 of the Pebble 1.0 plan. Forwards conversational queries
from the frontend's Ask mode to Pebble (separate process, port
8001) and streams the response back as SSE.

Why a Bedrock-side proxy instead of direct frontend → Pebble:
  * Single auth boundary. Frontend has Bedrock JWT; doesn't have
    Pebble's X-Api-Key. Proxy swaps creds.
  * Centralized cost / rate gating. One place to enforce
    per-user budget + audit row.
  * Single CORS surface — FE talks to one host.
  * Trace propagation gateway: X-Trace-Id minted at ingress,
    forwarded to Pebble + back to FE so FE → Bedrock → Pebble
    → Bedrock(/api/search) shares one correlation id.

routes/pebble_proxy.py:
  * POST /api/pebble/ask {query, conversation_id?, context?}
    streams text/event-stream to FE. Read-only at v1.0 — no
    write tools in Pebble's tool budget. Suggested-action cards
    require user confirmation via separate JWT-authenticated
    routes (/api/opportunities/update-stage, etc).
  * Permission gate: check_permission_or_internal("use_pebble_chat").
  * Service callers (Pebble calling itself in nested flows)
    propagate originating_user_email as Pebble's X-User-Email
    so Pebble grounds in the human's permissions, not the
    service principal's.
  * Pebble status >= 400 → SSE error frame (type=error, status).
  * Timeout (60s read) → SSE error frame (reason=timeout, 504).
  * Other httpx errors → SSE error frame (reason=upstream, 502).
  * Audit row written via BackgroundTask to bedrock.search_audit
    with mode='ask' — same table as Find queries, single
    "what did Pebble see for this user" surface.
  * Singleton httpx.AsyncClient with 60s read timeout, 3s connect.
    close() called from main.py shutdown.

13 tests covering: happy path streaming, Pebble error
surfacing, timeout / connection error fallback, query length
validation (1-2000 chars), audit BackgroundTask invocation,
trace_id propagation (kept when valid, minted when malformed),
service-caller originating-user delegation to Pebble's
X-User-Email, close() singleton reset.

main.py wire-up:
  * Mount /api/pebble/ask router.
  * close_pebble_proxy() in shutdown_event before close_db().

Plan refs: tasks/pebble-search-spec.md Layer 3.1
           tasks/pebble-search-spec-security.md §6 (degradation order)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 0.10 + Layer 2.2 of the Pebble 1.0 plan. Test infrastructure
established and the search modal becomes Find | Ask in one bar.

frontend-v2 test infrastructure (Layer 0.10):
  * vitest 3.2 + @testing-library/react 16 + jsdom 26 added.
  * vitest.config.ts wires alias resolution, jsdom env, coverage
    floor (lines/functions/branches/statements >= 60% on changed
    files in src/components, src/pages, src/lib, src/services).
  * src/test/setup.ts polyfills IntersectionObserver + matchMedia
    for jsdom; quiets React 19 act() noise from libraries that
    haven't migrated; auto-cleans up rendered trees after each test.
  * package.json scripts: test (single-shot), test:watch,
    test:coverage. CI gate flips advisory→required once coverage
    hits the floor (followup).

GlobalSearch dual-mode rebuild (Layer 2.2):
  * One bar, two modes: Find (default, /api/search typeahead) and
    Ask (Pebble streaming via /api/pebble/ask).
  * Mode triggers: segmented Find ⟷ Ask radiogroup at top, ?/
    prefix as first char hard-switches to Ask and consumes the
    prefix, Cmd/Ctrl+I toggles mid-query without losing it.
  * Permanent footer "Ask Pebble: <query>" chip when Find query
    >= 2 chars — closes the cmd-K-only discoverability gap UX
    adversary #3 flagged. Click chip → switch to Ask.
  * Find never silently routes to Ask. Wrong-Acme auto-promote
    to Ask is a trust killer; user always explicitly opts in.
  * Ask body streams SSE: parses `data: {...}\n\n` frames into
    {type: token|redirect|error|done}. Plaintext-only render —
    no rich HTML from LLM output, no <script>, no on-event.
    L0 redirect frames navigate via React Router. Errors
    surface as "Pebble had a problem" with try-again hint.
  * AbortController cancels in-flight Ask on mode switch / close
    so we never leak streams.
  * WAI-ARIA Editable Combobox: role=dialog/aria-modal=true,
    role=combobox/aria-expanded/aria-controls/aria-activedescendant
    on input, role=listbox/role=option for results, role=radiogroup
    with aria-checked on the mode toggle, aria-live=polite on the
    Ask response container. (Editable Combobox With List
    Autocomplete pattern — WAI-ARIA AP.)
  * Keyboard surface: ↑↓ nav (Find), Enter submit, Escape close,
    Cmd/Ctrl+I toggle mode, ?/ prefix.
  * sanitizeToken strips control characters from streamed tokens
    (defense-in-depth XSS guard).

AppShell sidebar:
  * New "Pebble" nav group with /pebble entry using the
    MessageSquarePlus icon. Permission gating handled at the
    route level; sidebar entry shown for all users so they
    discover the surface exists.

Tests (24 passing, 1.04s):
  * Open/closed: dialog renders only when open=true; aria-modal=true.
  * Mode toggle: default Find; clicking Ask radio switches.
  * Prefix detection: ? and / both switch to Ask and consume.
  * Cmd+I: toggles mode and preserves the typed query.
  * Find body: empty state hint, debounced /api/search call,
    no API call below 2 chars (validates the debounce).
  * Ask body: example prompts visible when query empty.
  * Ask chip: appears at >= 2 chars in Find mode, hides under,
    click switches to Ask.
  * Escape closes; backdrop click closes.
  * Helpers: detectModePrefix edges (empty / plain / ? / / / leading whitespace);
    sanitizeToken strips control chars.

Type-clean (npx tsc --noEmit). 24/24 tests green.

Plan refs: tasks/pebble-search-spec.md Layer 0.10, 2.2
           tasks/pebble-search-spec-ux.md §1, §5 (a11y), §6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…olver

PRODUCTION BUG. resolve_principal was reading from
``bedrock.org_users`` and ``bedrock.permission_profiles`` (plural).
Neither table exists. The canonical schema is:

  * ``public.org_users``  — identity; lives in the learning-platform
    parent app's schema. May not exist on local-dev DBs.
  * ``bedrock.user_config``  — joins org_users.id → profile_id.
  * ``bedrock.permission_profile`` (singular) — has the permissions
    JSONB.

The wrong query would have 500'd every search request the moment
it ran against a real database, with the test mock-conn passing
because we mocked the response shape instead of asserting against
real schema.

Fix: delegate to ``routes.permissions.get_user_permissions(email,
db)`` — the canonical resolver that already knows the live schema,
auto-provisions org_users rows on first Bedrock visit, and
defaults Admin profile keys to true. Lesson encoded in the
docstring: anything that looks like "user → profile → permissions"
must call the canonical resolver, never roll its own JOIN.

Behavior changes:
  * is_admin = (profile_name == "Admin") OR
    permissions.manage_users_roles. Earlier draft conflated
    is_admin with manage_users_roles only — Admin profile users
    without that flag were getting locked-out semantics.
  * has_view_all_* flags inherit from is_admin in addition to
    their respective edit_* permissions. Admin sees everything
    in their org regardless of granular permission state.
  * sf_user_id sourced from public.org_users.sf_user_id (added
    conditionally by init.sql migration step §B-1).
  * org_id hardcoded to "pursuit" — public.org_users has no
    org_id column today; multi-tenant retrofit is mechanical
    once that column exists. Reserved on bedrock.search_doc and
    SearchPrincipal so the schema doesn't need to change.

Fail-closed semantics: when get_user_permissions raises (DB
unreachable, schema migration in flight), return the
most-restrictive principal — search returns nothing, audit row
still logs the attempt for anomaly review.

Tests: 38 passing (was 35). New cases:
  * Admin profile name → all view_all flags true.
  * RM profile with partial perms → mixed view_all flags.
  * get_user_permissions failure → fail-closed restrictive principal.
  * Service caller resolves against originating_user_email,
    not pebble@internal (locks in delegated-principal contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
REGRESSION FIX. Commit f90099a made X-Originating-User mandatory
on internal-key calls to Bedrock. crm_bridge.py was never updated
to send it — every existing Pebble read/write path (search_all,
search_*, get_opportunities, create_*, update_*) was 401'ing
silently. The change is a breaking change disguised as a feature
unless the producer side is updated in lockstep.

Approach — context-var attribution propagation:

  pebble/request_context.py (new)
    * ContextVars for originating_user, request_id, trace_id.
    * set_originating_user(email, ...) context manager — populates
      the vars and restores on exit. Nested contexts honor
      LIFO restore so inner exits don't leak the outer state.
    * AUTONOMOUS_USER sentinel ("system:pebble-autonomous@pursuit.org")
      for cron / orchestrator runs with no human in the loop.
      Email-shaped so it passes Bedrock's format check; clearly
      labeled so audit dashboards distinguish it from real users.
    * attribution_headers() composes X-Originating-User +
      X-Request-Id (+ optional X-Trace-Id) from the current
      context. ALWAYS mints a UUID for X-Request-Id even when
      unset — Bedrock's UNIQUE(request_id) idempotency on the
      audit row depends on getting one.

  pebble/crm_bridge.py (12 call sites updated)
    * Every client.{get,post,put}() call now passes
      headers=_request_headers(). The static X-Internal-Key stays
      on the singleton client; per-request attribution merges
      via httpx's per-call headers kwarg.
    * _request_headers() logs a WARN when the originating user
      is unset — Bedrock will 401 the request, callers see
      None return (existing graceful-degradation contract).

  pebble/main.py (request middleware)
    * @app.middleware("http") attribute_request — reads
      X-User-Email (Pebble's existing header from Bedrock's
      proxy) or falls back to X-Originating-User. Wraps the
      request lifecycle in set_originating_user(...). All
      crm_bridge calls deeper in the stack auto-attribute via
      ContextVar inheritance across asyncio tasks.
    * CORS allow_headers extended with X-Request-Id + X-Trace-Id
      so browser preflight checks pass.

11 tests in pebble/tests/test_request_context.py:
  * Default state is unset.
  * set_originating_user populates + restores; with-block
    semantics verified.
  * Nested set_originating_user honors LIFO; outer state
    survives inner exit.
  * attribution_headers shape with / without ids; mints unique
    request_id per call.
  * AUTONOMOUS_USER passes Bedrock's email-shape validator
    (RFC-shaped, no whitespace, < 254 chars, "system:" prefix).
  * Concurrent asyncio.gather contexts do NOT cross-contaminate
    (locked in via 3 parallel workers each with its own email,
    each verified to observe only its own value).

29 pebble tests pass total (11 new + 18 existing bridge config).

Plan ref: tasks/pebble-search-spec.md decision §3 (delegated principal)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0.8. Closes the audit-table-but-no-writers gap I shipped
earlier — bedrock.pebble_write_audit existed since commit 2f2367f
but nothing wrote to it for the 11 existing internal-key write
routes. Pebble could create accounts/contacts/payments/opps with
no audit trail. The whole point of the audit table was missed.

services/pebble_audit.py:
  * PebbleWriteAuditMiddleware — Starlette middleware that
    transparently captures every successful internal-key write
    (POST/PUT/PATCH/DELETE) hitting a Pebble-relevant prefix
    (/api/salesforce/*, /api/opportunities/*, /api/payments,
    /api/payment-schedules, /api/projects, /api/awards). No per-
    route changes required; new write routes auto-audit on
    first deploy.
  * Reads request_id + originating_user from headers (set by
    Pebble's request_context middleware on the producer side).
    Idempotency via UNIQUE(request_id) on the audit row.
  * Captures path, method, response_status, latency_ms, side_effects.
    Skips body reading entirely — replay-into-handler fights with
    BaseHTTPMiddleware's receive-stream wrapper. Routes that
    need to capture sf_object_id (when it's in the body, not
    path) call record_sf_object(request, type, id). Routes that
    fire side effects call record_side_effect(request, key, value).
    update_opportunity_stage's auto-award handler will use this
    to attest award_created in the audit row.
  * _extract_sf_object_from_path uses an explicit
    PLURAL_TO_SINGULAR map — earlier rstrip("s").capitalize()
    naively turned "opportunities" into "Opportunitie" (caught
    by tests).
  * Skips audit for failed (>= 400) responses — attempt-rows are
    captured by upstream metrics + Cloud Logging instead.
  * Audit failure NEVER propagates to user response. asyncio
    create_task fire-and-forget; logger.exception on failure for
    Cloud Logging alerts.

main.py:
  * Mounts PebbleWriteAuditMiddleware via add_middleware.
  * pool_provider closure returns the asyncpg pool lazily so
    the audit module isn't coupled to main.py's import order.

31 tests in test_pebble_audit.py covering all 14 invariants:
  * is_audited_route gates: GET/HEAD/OPTIONS skipped, non-Pebble
    paths skipped, POST/PUT/PATCH/DELETE on prefix-matched routes
    audited.
  * is_internal_key_request: header presence required.
  * extract_sf_object_from_path: 8 path shapes including the
    "opportunities" plural fix.
  * record_side_effect / record_sf_object merge into request.state.
  * Middleware skips non-audited routes, JWT-only requests, and
    failed responses.
  * Successful internal-key write writes audit row with correct
    request_id / route / method / originating_user / status.
  * record_sf_object + record_side_effect propagate into the audit
    row (Opportunity 006XYZ + side_effects=award_created).
  * Audit insert exception swallowed; user response still 200.
  * Body NOT consumed by middleware — route receives the JSON.

Plan ref: tasks/pebble-search-spec.md decisions §3, §4
Adversary refs: pebble-adversary-security.md H3, M3
                pebble-adversary-architecture.md #6
                pebble-adversary-ux.md #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real production runaway-cost guard. Earlier the proxy forwarded
to Pebble unconditionally — a single user could exhaust the org's
daily token budget in minutes. Closes the gap I'd flagged in my
own audit.

routes/pebble_proxy.py:
  * _DAILY_COST_LIMIT_USD env-driven default $5/user/day —
    matches pebble/main.py's same-named limit so users don't
    see different caps depending on which side throttles first.
  * _COST_DEGRADE_FRACTION = 0.80 — at 80%+ of cap, proxy adds
    X-Pebble-Force-Tier: L0 to upstream headers and X-Pebble-
    Degraded headers to the response. Pebble's router honors
    the hint to skip L1+ LLM calls and only emit deterministic
    L0 redirects (cheap nav). Even if Pebble doesn't honor it
    yet, the response header tells the FE to render a "running
    low" banner.
  * At 100% cap → 429 with structured payload (spent_usd,
    limit_usd, query_count_today) + Retry-After (seconds to
    midnight UTC, min 60).
  * 429 audit row emitted via asyncio.create_task — FastAPI's
    default HTTPException handler doesn't run the request's
    BackgroundTasks, so the fire-and-forget pattern is required
    here. Anomaly detection NEEDS to see 429s — they often
    signal spam.
  * _read_daily_cost is fail-open: DB outage returns (0, 0) so
    users aren't locked out by an unrelated incident. Sustained
    failures alert via the logger.exception call (Cloud
    Monitoring picks up warning rate).
  * _seconds_until_midnight_utc bounded [60, 86400] so an absurd
    Retry-After (e.g. clock skew) doesn't lock a client out
    for years.

Tests added (8, 23 total in file):
  * Cap exceeded → 429 with full payload shape + Retry-After.
  * Cap exact-equals → 429 (>= comparison).
  * Below cap → 200, request flows through normally.
  * 80%+ → X-Pebble-Force-Tier: L0 sent to upstream;
    X-Pebble-Degraded / X-Pebble-Cost-Today / X-Pebble-Cost-Limit
    in response.
  * Below 80% → no degradation headers anywhere.
  * 429 still emits audit row (via asyncio.create_task path).
  * _read_daily_cost: DB error → (0, 0); empty row → (0, 0);
    no email → (0, 0).
  * _seconds_until_midnight_utc bounded.

Plan ref: tasks/pebble-search-spec-security.md §3 (rate limiting)
          tasks/pebble-search-spec-architecture.md #13 (cost cap)
Adversary refs: pebble-adversary-architecture.md #13
                pebble-adversary-security.md M5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three production bugs found by running the FULL test suite +
smoke-importing every module + auditing schema assumptions:

1. pebble.crm_bridge._request_headers was CALLED 12 times but
   NEVER DEFINED. The function definition got lost when an
   intermediate ``git checkout`` reverted my earlier edit; the
   regex pass that injected ``headers=_request_headers()`` into
   every call site landed AFTER the function disappeared. Pebble
   would have failed to import in production. Caught by a smoke
   test that exercised the import path; unit tests didn't catch
   it because they mock the request methods.

2. Test pollution in test_search_route.py — ``search_route.ss.X
   = fake_X`` raw assignment instead of monkeypatch.setattr
   leaked across the test session. test_search_service.py
   passed alone but failed in the full suite (9 tests). Caught
   by running the full pytest suite (1052 tests) instead of
   targeted file runs. Same pattern fixed in test_pebble_proxy.py
   for ``pebble_proxy._get_pebble_client``.

3. compose_bedrock_project read the wrong column for owner. It
   read created_by — the original author — not owner_email
   (added by the 2026-04-21 multi-owner support). The search
   filter would attribute ownership to the project creator
   instead of the current owner; ownership-transferred projects
   would be visible to the wrong RM. Now reads owner_email with
   created_by as fallback for legacy rows.

Fixes:
  * pebble/crm_bridge.py:1-83 — restore _request_headers helper
    + complete docstring / context-var integration explanation.
  * financial_forecasting/tests/test_search_route.py:74-130 —
    monkeypatch.setattr for module-level fakes; add test docstring
    explaining the cross-test-pollution failure mode.
  * financial_forecasting/tests/test_pebble_proxy.py:74-130 —
    same fix; convert raw lambda assignments to monkeypatch.setattr.
  * services/search_indexer.py:compose_bedrock_project — read
    owner_email; fall through to created_by for pre-2026-04-21
    rows; new test_compose_bedrock_project_falls_through_to_created_by
    locks in the legacy-project case.

Verification: 1052 backend + 390 pebble + 24 frontend tests all
pass. Full-suite run (not just per-file) confirms no
cross-pollution. main.py + pebble.main.py smoke-import clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Establishes the foundation for Pebble as a real BI assistant
using every agentic pattern from the Claude Certified Architect
curriculum. See tasks/pebble-bi-architect.md for the architecture
spec.

Patterns applied + where they live:

  * Orchestrator-worker — pebble/orchestrator/ package layout
    (planner, executor, evaluator forthcoming).
  * Bounded autonomy — pebble/orchestrator/budget.py with HARD
    caps on tool_calls / cost_usd / wall_seconds. Pre-flight
    `can_afford()` rejects plans whose estimates would exceed
    the cap before any tools fire. Exhaustion = clean halt with
    structured halt-reason.
  * Scratchpad / state externalization — pebble/orchestrator/
    scratchpad.py + bedrock.pebble_chat_scratchpad migration.
    Every step (plan, tool_call, tool_result, evaluation, render,
    conflict, checkpoint, error) persists. Tree-shaped via
    parent_step_id so re-plans branch without losing the
    original failed plan.
  * Tool use (formal) — pebble/orchestrator/tools.py with
    Anthropic-shape ToolSpec (name / description / input_schema
    / handler / cost_estimate / requires_human). ToolRegistry
    enforces unique names + async handlers + dispatches with
    exception-wrapping (a broken tool fails its step, never
    crashes the orchestrator).
  * Citation / provenance — schemas.Citation type; ToolResult
    carries citations tuple that the renderer wraps as
    <cite> spans.
  * Disambiguation — checkpoint step_type + SuggestedAction
    schema with diff_preview + record_label so the FE confirm-
    card can show "Acme · 006XYZ" anti-mistake guard.
  * Workflow vs agent — package layout reserves
    pebble/workflows/ for deterministic compositions; agent path
    only fires for novel queries.
  * Bounded recursion — Plan validator rejects forward references
    in depends_on; planner must topologically order steps.
  * Resource accounting — every ToolResult carries duration_ms,
    cost_usd, tokens_in, tokens_out so post-run telemetry has
    real numbers, not estimates.

bedrock.pebble_chat_scratchpad migration:
  * Tree-shaped (parent_step_id nullable, indexed when present).
  * step_type CHECK enum matches StepType Pydantic enum exactly
    — locked in via test_step_type_enum_values canary test.
  * Indexes for replay (conversation_id, step_number),
    per-user activity surface (user_email, created_at DESC),
    error queue (created_at WHERE step_type IN errors), cost
    rollup (conversation_id, cost_usd).
  * Append-only — no UPDATE/DELETE grants for bedrock_user.
    Reasoning chains are immutable; retention is a separate
    role's job.

Tests (71 total, 0.14s):
  * test_orchestrator_schemas.py (27) — Plan/PlanStep validators,
    frozen-immutability, depends_on forward-ref rejection,
    enum bounds, citation shape.
  * test_orchestrator_budget.py (19) — each cap trips
    independently; charge() rejects negatives; can_afford()
    pre-flight; monotonic time (not wall — DST-safe);
    snapshot shape; remaining clamps to zero.
  * test_orchestrator_tools.py (15) — registry isolation;
    async-only handler; unique name; dispatch wraps exceptions;
    Anthropic-shape export; insertion-order iteration;
    DEFAULT_REGISTRY separated from test registries.
  * test_orchestrator_scratchpad.py (10) — auto-mint UUID;
    monotonic step_number; INSERT shape + parameter order;
    JSONB pre-serialization (handles UUID via default=str);
    failure-soft (logs but doesn't raise); pool-less degraded
    mode for tests.

Plan ref: tasks/pebble-bi-architect.md
Standing memory: feedback_session2_architecture, feedback_agentic_principles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uator, renderer

Builds the agentic-pattern foundation for Ask-Pebble L1+ chat per the
Anthropic Architect curriculum's session-2 patterns:

  * planner.py — Sonnet emits a Plan; jsonschema-validates each step's
    args against the registered tool's input_schema; retry-once on
    malformed output with the validation error fed back; DI client
    so tests pass a stub.
  * executor.py — runs the Plan with hard budget enforcement, persists
    every step to the scratchpad, halts on checkpoints / tool failures
    with downstream deps / budget exhaustion / pre-flight rejection.
  * evaluator.py — Haiku-as-judge; scores factuality + completeness
    + harm; PASS / RETRY / ABORT verdict; failsafe PASS on judge
    outage so a transient eval failure doesn't block users.
  * renderer.py — deterministic per-tool templates (search_crm,
    get_record) into a FinalResponse; degraded-mode messages for
    pre-flight, budget, tool-failure, checkpoint outcomes; citation
    collection deduped across results.
  * builtin_tools.py — search_crm / get_record / request_human_review
    wired to /api/search and Bedrock REST routes, citations emitted
    in entity_type:entity_id format the renderer wraps.
  * chat_orchestrator.py — top-level loop: plan → execute → render →
    evaluate → optionally re-plan once → final. Streams ordered SSE
    events (plan_emitted, tool_call_started/finished, draft_emitted,
    eval_emitted, replan_started, response_final, error) the FE
    consumes for live agent-view rendering.

Package restructure: the legacy single-file ``pebble/orchestrator.py``
(prospect-research pipeline) moves to ``pebble/orchestrator/_pipeline.py``
with public symbols re-exported from the new package's __init__ so
``pebble.handlers.tier3``, ``pebble.clusters``, etc. keep working
without changes.

Tests: 117 new (27 executor + builtin_tools, 20 planner, 25 evaluator,
22 renderer, 13 chat_orchestrator + 10 already counted in executor);
all 497 pebble + 1052 backend tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This branch (feat/pebble-phase-0) is being pushed to origin so the
work is saved off-machine, but it is NOT ready to merge. See
tasks/pebble-phase-0-PARKED.md for what's done, what's missing, and
how to resume.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, SSE, /pipeline workflow

Backend integration of the orchestrator package built in 45fbac6 with
production-grade pieces tying everything together.

What ships in this commit:

  * **pebble/llm/** — AsyncAnthropic-backed implementation of
    PlannerLLMClient + EvaluatorLLMClient protocols. Prompt caching
    on system prompts (~20% cost reduction on cache hits). Token-cost
    accounting in pebble/llm/cost.py keyed by model id with high-water
    fallback for unknown models. Singleton lifecycle managed via
    get_default_client / close_default_client. 54 tests + 2 cassettes.

  * **chat_orchestrator refactor** — extract post-planner pipeline into
    run_stream_with_plan(plan, allow_replan=False) so workflows can
    feed pre-baked Plans through executor + render + eval without a
    planner LLM call. Existing run_stream remains the agent path. +5 tests.

  * **generate_chart tool** — pure shape transformer (data + kind →
    ChartSpec). Auto-registers in DEFAULT_REGISTRY. JSON-schema enum
    on kind defends against planner inventing chart types. +14 tests.

  * **pebble/workflows/weekly_pipeline_review.py** — first worked
    example. Three views (at-risk renewals, stale opps, coverage by
    owner) sourced from one crm_bridge.get_opportunities call.
    Aggregations in pure Python — pre-mirror; swaps to SQL when
    sf_*_mirror tables ship. Plus three Recharts-shape ChartSpec
    dicts. Registered as `aggregate_pipeline_views` tool so the
    L1 planner can also invoke it for natural-language requests.
    +53 tests.

  * **/pipeline slash command** — router.classify_query detects
    `/pipeline` first-token, returns RouteResult(level=2,
    intent='workflow_weekly_pipeline_review'). Bypasses LLM
    classification entirely. +12 tests.

  * **pebble/orchestrator/sse.py** — canonical SSE encoder.
    {kind, payload} wire format with default=str for UUIDs and
    datetimes. encode_event / encode_error / encode_keepalive
    helpers. +11 tests.

  * **pebble/handlers/streaming.py** — orchestrator construction +
    dispatch entry. orchestrator_enabled() predicate gates level=1
    behind PEBBLE_USE_ORCHESTRATOR env flag for safe rollout.
    is_workflow_route() handles level=2. Clean error stream on
    Anthropic SDK failure or unknown workflow intent. +27 tests.

  * **pebble/main.py:chat_query** — switches between SSE streaming
    (level in {1+orch, 2}) and existing JSON ChatQueryResponse path
    (everything else). Honors X-Pebble-Force-Tier=L0 from the
    Bedrock-side proxy when daily-cost cap is at 80%+. Persists the
    streamed assistant message to chat history at end of stream.

  * **renderer.py** — _collect_charts() walks tool results harvesting
    ChartSpecs from both generate_chart and aggregate_pipeline_views
    shapes. Invalid specs skipped, never crash. Per-tool render
    branches for the new tools.

  * **routes/pebble_proxy.py** — error frame shape switched from
    {type:'error',...} to {kind:'error', payload:{phase, reason, ...}}
    matching the orchestrator's canonical event vocabulary.

Test totals: 1052 backend + 672 pebble = 1724 (was 1052 + 497 = 1549).
+175 new pebble tests; zero regressions on backend.

Branch stays parked. PR opens when JP says.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…agentic UX glass-box

Companion to a2d3c2f. Wires the orchestrator's {kind, payload} SSE
event stream into the Pursuit frontend.

What ships in this commit:

  * **types/pebble.ts** — discriminated union on `kind` for every
    OrchestratorEvent (plan_emitted, tool_call_started/finished,
    draft_emitted, eval_emitted, replan_started, response_final,
    error). Plus FinalResponse / Citation / ChartSpec /
    SuggestedAction matching the Pydantic schemas. Compile-time
    catch on missed cases when the server adds new event kinds.

  * **services/pebble.ts** — streamPebbleAsk async generator. Fetch +
    ReadableStream + SSE frame parser. Reassembles frames split
    across chunks, drops malformed JSON, skips :keepalive comment
    frames. Cancellation via AbortSignal swallows AbortError; other
    failures yield synthetic { kind: 'error' } events. +10 tests.

  * **context/PebbleConversationContext.tsx** — useReducer-driven
    conversation state. One PebbleTurn per user query. Reducer
    branches on event.kind via TypeScript discriminated union. Auto-
    cancels in-flight stream on unmount or conversation_id change.
    +14 tests.

  * **components/pebble/** — six reusable primitives:
      - ChartRenderer.tsx — switches on ChartSpec.kind to dispatch
        the right Recharts component. Pursuit's neutral palette.
        Empty data renders a "no data" caption rather than crashing.
        ARIA: figure[role=img] + aria-label.
      - CitationList.tsx — numbered footnote list. Internal nav
        via react-router-dom Link.
      - PlanTrace.tsx — plan-as-todos with status icons (pending /
        in_progress / done / failed). Live during streaming;
        collapses by default once final shipped. Shows eval verdict
        with color-coded factuality + completeness.
      - MessageInput.tsx — auto-grow textarea. Enter sends,
        Shift+Enter inserts newline, Cmd+Enter sends. Cancel button
        replaces send while streaming.
      - ConversationView.tsx — turn-by-turn renderer. User query as
        right-aligned bubble; PlanTrace card; assistant response with
        charts + citations. Auto-scroll to bottom on update.
        Empty-state with /pipeline hint.
      - (page) Pebble.tsx — single-conversation /pebble surface.
        3-column layout primitive (sidebar slot empty in L1, will
        host history list in L1.5). URL parameter ?conv=<uuid> ties
        page state to a specific conversation for shareability.

  * **App.tsx** — adds /pebble route inside AuthGate. Sidebar entry
    in AppShell already existed; this resolves the dead-link.

  * **GlobalSearch.tsx** — Ask mode rewritten to consume the new
    {kind, payload} stream via streamPebbleAsk. Inline modal renders
    just draft_emitted text + response_final + error (plan/tool/eval
    frames dropped — the deep-dive view is /pebble). Adds "Continue
    in Pebble →" CTA after answer arrives, navigating to
    /pebble?conv=<conversation_id> so the user lands on the same
    trace with the full agent view.

Test totals: 24 → 48 (+24 new). Full backend + pebble + FE: 1772
(was 1573 at parking point).

Branch stays parked. PR opens when JP says.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ld artifacts

  * tasks/pebble-phase-0-PARKED.md — reflect L1 scope shipped:
    Anthropic client, SSE, slash command, workflow, frontend panel.
    Test totals 1573 → 1772. Lists L2 follow-ups (propose_write,
    sf_*_mirror, additional workflows, history sidebar, omnibox).

  * frontend-v2/.gitignore — exclude *.tsbuildinfo + the
    vite.config.{d.ts,js} artifacts ``tsc -b`` emits during build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gleton

Per pass-3 review of L1 wire-up: the streaming entry was constructing
a fresh AsyncAnthropic per request (via AnthropicLLMClient()),
discarding the httpx connection pool and the prompt cache benefit
between calls. Switch to get_default_client() — process-wide
singleton, lifespan-managed.

Tests: 672 pebble + 1052 backend stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n view

Two paired UX additions on top of the L1 wire-up.

Running cost / token tally
--------------------------

Surfaces every LLM call's spend so users can see what their
conversation costs in real time.

  * **schemas.Evaluation** gains cost_usd / tokens_in / tokens_out
    (defaults preserve back-compat). Evaluator carries the LLM-call
    accounting from EvaluatorLLMResponse onto the Evaluation model.
  * **chat_orchestrator** event payloads:
      - tool_call_finished now includes tokens_in / tokens_out
        (already had cost_usd).
      - eval_emitted now includes cost_usd / tokens_in / tokens_out
        (the judge LLM call's spend was previously discarded).
  * **chat_query SSE body** sums cost + tokens across both event
    kinds; writes tokens_in / tokens_out into the persisted
    chat_messages.metadata for history surfaces.
  * **Frontend types** extend ToolCallFinishedPayload + EvalEmittedPayload
    + PebbleTurn (per-turn totals) + StepView (per-step tokens).
  * **PebbleConversationContext reducer** accumulates spend on every
    tool_call_finished and eval_emitted into the turn's totals; a
    useMemo aggregates across all turns into a `totals` value
    exposed on the context.
  * **PlanTrace** renders cost + tokens per step (e.g. "$0.0094 ·
    300↓120↑") plus turn totals in the collapsible header.
  * **PebblePage header** gets a running-total chip:
    "3 turns · $0.034 · 5,200 tok".

Collapse to deck-of-cards
-------------------------

Header toggle button switches the conversation column between full
chat flow and a stacked-card deck per JP's spec ("don't hide it when
collapsed").

  * **ConversationView** accepts a `collapsed` prop. When true,
    renders a DeckView: each turn becomes a 56px card stacked
    vertically with negative margins so the edge of every card peeks
    above the next. Newest card on top, z-index decreasing into the
    stack. Click any card to "draw" it — the selected card expands
    inline with the full TurnView; "Tuck back ↑" returns it to the
    deck. Cards keep their status icon + step count + cost preview
    in the compact state, so nothing is hidden.
  * **PebblePage** mounts a Layers/Maximize2 toggle button in the
    header. When collapsed=true the deck takes over the conversation
    area; the MessageInput stays visible at the bottom regardless,
    so the user can keep asking from either mode.

Tests
-----

  * Pebble: 672 → 678 (+6 new — evaluator cost-plumbing × 4,
    chat_orchestrator event payload × 2). Zero regressions.
  * Frontend: 48 → 51 (+3 — totals aggregator × 3).

Branch stays parked. PR opens when JP says.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsc -b --noEmit` errors on TS 5.7+ with composite project
references (TS6310: "Referenced project ... may not disable
emit"). Build mode handles emit per-referenced-project; the
global --noEmit flag is incompatible.

`tsc -b` alone produces .tsbuildinfo files but no .js output
for typecheck-only validation, which is what we want and what
the script was already trying to achieve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flip the PARKED doc to OPEN FOR REVIEW. Content preserved as
the design/scope reference for reviewers (covers Phase 0
foundation + L1 chat orchestrator wire-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing meta-system

Scopes Chisel as the meta-tool that lowers Pebble's per-tool authoring
cost from ~4–8h (8 stitching points across 6+ files, plus a silent-failure
side-effect import in handlers/streaming.py) to ~30 min, and extends
authoring to non-engineers via a GUI builder.

Locked decisions (D1–D8) as of 2026-05-11:
- Audience: engineers + non-engineers via GUI builder
- Unit: tools + workflows
- Authoring: hybrid manifest.yaml + handler.py per artifact; Pydantic
  → JSON Schema; Sprint-12 RBAC integration
- External emit: none; forward-compat to internal platform stack only
  (segundo-db Postgres + asyncpg + JWT + React/Tailwind/shadcn)

Delivery: three cumulative phases — A. Framework → B. Eval harness →
C. GUI builder — ~6–7 weeks at production discipline.

Doc includes:
- File-by-file friction walkthrough for the existing authoring loop
- Verification log for every load-bearing architectural claim
- Implementation defaults pending plan-mode entry

Plan mode + tasks/pebble-chisel-plan.md pending user approval of this scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ase A

- tasks/pebble-chisel-plan.md (NEW): 635-line implementation plan
  resolving P1-P14 from scoping §7.5. Three PRs in order:
  Phase A (Framework ~2wk) → B (Eval ~1wk) → C (GUI ~3-4wk). Locks
  package layout, manifest schema, RBAC integration, migration
  sequencing, per-phase test surface + risk register.

- tasks/pebble-chisel-scoping.md: 2026-05-12 verification pass
  corrects aspirational stack claims against reality —
  session-cookie auth (not JWT), custom UI primitives in
  frontend-v2/src/components/ui/ (not shadcn/ui), snake_case
  RBAC names matching use_pebble_research. Pulls handler/render
  code editing out of v1 GUI scope (PR review only). Workflow
  GUI authoring is declarative (workflow.yaml) — Python
  build_plan.py stays engineer-only.

Phase A implementation begins next commit.
…chema strictness

Phase A.1 of the chisel plan. Lays the framework down with empty
tools/ and workflows/ dirs so autoload is a no-op until subsequent
commits migrate the four built-in tools + weekly_pipeline_review.

Resolves plan §11.8 step 1 (framework-first sequencing). No existing
pebble files behaviourally affected; orchestrator tests still pass (112/112).

New package: pebble/chisel/
- __init__.py        public surface (autoload, snapshot, slash_command_map, dispatch_workflow)
- manifest.py        Pydantic ToolManifest + WorkflowManifest; FixedCost/VariableCost (P8)
- schema.py          pydantic_to_strict_schema injects additionalProperties:false at every object node (P1) + assert_strict invariant
- handler_adapter.py HandlerContext + build_handler_wrapper — dict→pydantic, timing, exception→ToolResult, tool_version (P2, P11)
- autoload.py        directory walk; AutoloadReport surfaces per-unit errors instead of crashing; registry= arg for test isolation (P4)
- rbac.py            check_permission stub with PEBBLE_CHISEL_RBAC_BYPASS_USERS env (§11.9), Sprint-12-ready
- lints.py           AST checks: no bare httpx, async run required, no os.environ in run()
- reload.py          snapshot(registry) for snapshot-per-request safety (P5)
- cli.py             argparse: list, validate, scaffold (P10 test-stub generator)
- tools/, workflows/ empty placeholders for migrations

Adjacent changes:
- ToolResult: optional tool_version field (P11 — Sprint-11 scratchpad forward-compat)
- pebble/requirements.txt: PyYAML>=6.0 for manifest parsing
JP added 7 commits May 22, 2026 09:24
Covers plan §8 framework surface end-to-end:

- manifest: minimal-valid, bad-name rejection, extra-field rejection,
  variable-cost collapse, workflow steps-or-custom-plan invariant,
  slash-command format
- schema (P1): strict invariant on nested model, $ref inlined,
  assert_strict flags permissive objects
- handler adapter (P2, P11): happy path records tool_version + citations
  + duration_ms; input-validation, exception, and non-dict-return paths
  all produce ok=False with a typed error
- autoload (P4): empty-root no-op, isolated registry, malformed manifest
  doesn't poison siblings, workflow populates slash + intent maps,
  DEFAULT_REGISTRY untouched by tests
- rbac (§11.9): no-requirement always ok, bypass env list (case-insensitive),
  missing permission, PEBBLE_CHAT_ALLOWED_EMAILS fallback
- lints: bare httpx, sync run, os.environ in run, overrides suppress
- snapshot (P5): survives source mutation mid-request

Full pebble suite green: 702 passed.
Phase A.2 — simplest tool first per plan §11.8. No I/O, no real handler
logic, output_kind=checkpoint so the renderer's checkpoint pipeline
handles surface presentation (P7 discriminator at work).

Handler shrinks from ~40 lines of ToolResult boilerplate to ~10 lines
of business logic — the adapter (P2) handles dict→Pydantic parsing,
exception wrapping, timing, and ToolResult construction. requires_human
flows from the manifest into the registered ToolSpec.
Phase A.3 — pure shape transformer, no I/O. Literal-typed ChartKind
gives the planner exactly the same enum the legacy schema had, and the
Pydantic model rejects non-object rows before the handler runs (the
legacy code had to hand-iterate). output_kind=chart so the renderer's
chart pipeline picks it up.

Handler is 12 lines of business logic; the legacy was 60 lines of
validation + ToolResult construction.
Phase A.4 — single-record fetch via ctx.http_client. Literal-typed
EntityType replaces the legacy enum schema and gives Pydantic
validation at parse time (used to be hand-checked inside the handler).

Handler raises RecordNotFound / RecordFetchError on 404 / non-2xx;
the adapter converts these to ToolResult ok=False with a clean type
name in the error string. Boilerplate down from ~55 lines to ~25.
Phase A.5 — last individual tool. Literal type for SearchEntityType
mirrors the legacy enum; Pydantic Field constraints (min/max length,
ge/le for limit) replace inline validation. ctx.cite() per hit replaces
the hand-rolled citation tuple construction.

Adapter eliminates timeout / status-code / parse boilerplate from the
handler — ~75 lines down to ~30.
Phase A.6 — the final migration commit. Brings aggregate_pipeline_views
across as a chisel tool and weekly_pipeline_review across as a chisel
workflow with a custom build_plan.py (the engineer escape hatch per
plan §3). Cutover wires production paths through chisel and deletes
the legacy modules — no coexistence with the old registration pattern.

Migrations:
- pebble/chisel/tools/aggregate_pipeline_views/
    compute.py (pure helpers, isolated for testing) + handler.py
    + manifest + tests. PipelineFetchError replaces inline ToolResult(
    ok=False).
- pebble/chisel/workflows/weekly_pipeline_review/
    workflow.yaml (slash_command=/pipeline, dispatch_intent=
    workflow_weekly_pipeline_review, has_custom_plan=true)
    + build_plan.py (factory replacing the legacy
    build_weekly_pipeline_review_plan).

Framework extensions:
- autoload now loads build_plan.py for has_custom_plan workflows and
  synthesizes a build_plan from declarative steps[] otherwise.
- chisel.build_workflow_plan(intent_or_name, **kwargs) → Plan | None
- chisel.slash_to_intent(slash) → dispatch_intent | None
- _import_module uses standard importlib for source-tree modules so
  relative imports (from .compute import ...) resolve; tmp_path tests
  still fall through to spec_from_file_location.

Cutover (replaces legacy side-effect imports per plan §11.8):
- pebble/handlers/streaming.py — drops `from ..orchestrator import
  builtin_tools` and `from .. import workflows`; calls
  `chisel.autoload()` at module load so DEFAULT_REGISTRY is populated
  from manifests. `_build_workflow_plan_for_intent` becomes a one-liner
  delegating to chisel.build_workflow_plan.
- pebble/router.py — drops the hand-rolled `_SLASH_COMMANDS` table;
  `_check_slash_command` reads `chisel.slash_to_intent()` at request
  time.

Deletions (no coexistence per §11.8):
- pebble/orchestrator/builtin_tools.py
- pebble/workflows/ (whole package)
- pebble/tests/test_orchestrator_builtin_tools.py
- pebble/tests/test_workflows_weekly_pipeline_review.py

Test isolation:
- conftest.py adds autouse _chisel_real_autoload fixture so framework
  tests that autoload from tmp_path don't pollute the global maps that
  router / streaming tests depend on.

Suite: 647 pass (702 pre-cutover − 60 legacy − a few replaced by
chisel-side equivalents + 28 new chisel tests).
Phase A.7 doc. Documents the chisel scaffold flow, the manifest +
handler shape, the workflow modes (custom build_plan vs declarative
steps), and the framework guarantees (strict schemas, no handler
boilerplate, isolated autoload, snapshot-per-request).

Cross-references tasks/pebble-chisel-plan.md for the full design.
@jpb33333
jpb33333 requested a review from jacrev-pursuit as a code owner May 22, 2026 14:10

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, push a new commit or reopen this pull request to trigger a review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant