diff --git a/.env.example b/.env.example index 4442b400..775d3afc 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,14 @@ LOG_LEVEL=info # DATABASE_SSL=false # DATABASE_CA_CERT=/path/to/ca.pem +# --- Optional: Router --- + +# Settle window (ms) for coalescing PM status-change webhooks into one dispatch. +# Any dispatch for the same project + work item inside the window supersedes the +# pending one (across agent types); the ack comment is deferred to job fire time. +# Default 10000. 0 disables. The legacy name PM_CREATE_COALESCE_WINDOW_MS is accepted. +# PM_COALESCE_WINDOW_MS=10000 + # --- Optional: Security --- # AES-256-GCM encryption key for credentials stored in the database. @@ -48,7 +56,7 @@ LOG_LEVEL=info # OAuth token for Claude Code SDK (Claude Max subscription). # Generate with: claude setup-token -# See CLAUDE.md for full setup instructions. +# See docs/getting-started.md ("Choose Agent Engine") for setup. # CLAUDE_CODE_OAUTH_TOKEN= # --- Optional: Monitoring --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 42861cc9..1f272cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ All notable user-visible changes to CASCADE are documented here. The format is l ### Documentation +- **`CLAUDE.md` / `AGENTS.md` cut from 217 lines plus 95 KB of `@`-imports to ~75 lines; path-scoped notes moved to `docs/areas/`.** The root file is loaded by Claude Code and `cat`-injected through CASCADE's `contextFiles` context step (inline only under `CONTEXT_OFFLOAD_CONFIG.inlineThreshold`), so it now carries only universal commands, gotchas, hard invariants, an environment pointer and a "read this before touching that area" table. Per-area rules live in `docs/areas/{pm-integrations,router-dispatch,agents,backends}.md` (imperatives + links, ≤ 60 lines each); mechanism stays in `docs/architecture/`; env vars are catalogued in `.env.example`. Facts whose only home was `CLAUDE.md` were placed first: PR checkout by `refs/pull/N/head` → `01-services`; review-context budget and debugging (`REVIEW_DIFF_CONTEXT_TOKEN_LIMIT`, `PR context prepared`, informational CI status) → `03-trigger-system`; `claimReviewDispatch`, `UnrecoverableError`, the reviewer-persona check and `formatCrashReason` → `03`/`10-resilience`; integration-DB discovery order → `tests/README.md`; `PM_COALESCE_WINDOW_MS` → `.env.example`. The stale `## Git hooks` paragraph (it described an integration pre-push that never existed) is replaced with the real `lefthook.yml` contract. `tests/unit/architecture-docs.test.ts` now enforces the budget — ≤ 200 lines, under half of `CONTEXT_OFFLOAD_CONFIG.inlineThreshold`, no `@` imports, no ticket IDs / spec numbers / dates in `CLAUDE.md` or `docs/areas/`, every area doc ≤ 60 lines with an `**Applies to:**` scope line and linked from the root — and `tests/unit/repo-hygiene.test.ts` pins the shared root/`web/` Zod major. `src/integrations/README.md` lost its spec-changelog preamble and the "Provider migration status" / "Post-spec-010/011/012" tables (their durable facts were folded into the current-state sections; history lives here); the JIRA `authType` contract now has one home there, with `08-config-credentials` and `getting-started` pointing at it. `SECURITY.md`, `README.md` and `CONTRIBUTING.md` no longer point at `CLAUDE.md` for reference material. The `documentation-maintenance` prompt partial routes agents to the same homes; DB-seeded partials shadow disk, so deployment still requires an explicit `npm run db:seed-prompts`. + - **Friction reporting is now documented for operators and provider contributors.** Architecture docs cover the optional PM Friction slot (`lists.friction` for Trello, `statuses.friction` for JIRA/Linear), `ReportFriction`, and `cascade-tools pm report-friction --details-file -`. The integration guide explains that friction reports use existing provider `createWorkItem` plus optional `moveWorkItem`, so providers do not need a new adapter method or a DB-backed friction index. Resilience docs describe the JSONL sidecar/outbox retry path, missing-slot behavior, and non-blocking drain failures. See Trello card [Rvv7VVd5](https://trello.com/c/69ff6af3bc5c526cc5faa2d4). - **Trigger architecture docs now describe the migrated trigger contracts.** Added guidance for canonical `TRIGGER_EVENTS`, shared PM/GitHub result builders, first-match dispatch, structured skip vs bare `null`, no-agent results, deferred bare-job re-checks, router outcome decision reasons, PM coalescing, capacity scope, dispatch failure compensation, and wedged-lock diagnostics. Migration note for future trigger contributors: new handlers should import event constants, use the shared builders, return structured skips for claimed-but-non-dispatched events, and reserve bare `null` for "continue to later handlers." See Trello card [qUbPtALY](https://trello.com/c/69fe2a950699baaf91688a5b). diff --git a/CLAUDE.md b/CLAUDE.md index 5ec5e0ec..91691afe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,217 +1,76 @@ # CASCADE — PM-to-Code Automation Platform -## Quick start +Webhooks from PM tools (Trello, JIRA, Linear), GitHub and Sentry drive AI coding agents: `webhook → Router → Redis/BullMQ → Worker container → TriggerRegistry → agent (claude-code | codex | opencode) → code → PR`. Three separate services, no monolithic mode: -```bash -npm install -cd web && npm install && cd .. -# Redis required (router/BullMQ). `.cascade/setup.sh` installs + starts it. -npm run dev # Router (webhook receiver, :3000) -npm run dev:web # Dashboard frontend (:5173, separate terminal) -node dist/dashboard.js # Dashboard API (:3001, third terminal, after `npm run build`) -``` - -> `npm start` runs the **router** (`dist/router/index.js`), **not** the dashboard. - -## Architecture - -Three separate services, **no monolithic server mode**: - -1. **Router** (`src/router/index.ts`) — receives webhooks, enqueues to Redis/BullMQ. -2. **Worker** (`src/worker-entry.ts`) — processes one job per container, exits. -3. **Dashboard** (`src/dashboard.ts`) — tRPC API + static frontend for web UI and CLI. - -Flow: `PM/SCM/alerting webhook → Router → Redis → Worker → TriggerRegistry → Agent → Code → PR`. - -**Capacity-gate invariant.** Every PM router adapter (`src/router/adapters/{linear,trello,jira}.ts`) must wrap `triggerRegistry.dispatch(ctx)` in PM-provider `AsyncLocalStorage` scope via the shared `withPMScopeForDispatch(fullProject, dispatch)` helper at `src/router/adapters/_shared.ts` — in addition to the per-PM-type credential scope (`withLinearCredentials` / `withTrelloCredentials` / `withJiraCredentials`). Without the PM-provider wrapping, the pipeline-capacity gate at `src/triggers/shared/pipeline-capacity-gate.ts` cannot resolve `getPMProvider()`, **fails closed** under the spec-017 fail-closed policy (blocks the run + ERROR + Sentry capture under tag `pipeline_capacity_gate_no_pm_provider`), and `maxInFlightItems` is silently disabled for the PM-source path. Mirror the GitHub adapter's existing correct shape at `src/router/adapters/github.ts:dispatchWithCredentials`. The static guard at `tests/unit/integrations/pm-router-adapter-pm-scope.test.ts` enforces this at CI time — adding a new PM router adapter without the wrapping fails CI with a precise file path. - -Integration abstraction lives in `src/integrations/`. For **adding a new PM provider**, see @src/integrations/README.md — PM providers (Trello, JIRA, Linear) use the `PMProviderManifest` registry with a **behavioral conformance harness** (spec 009 — config round-trip, discovery shape, full lifecycle scenario, auth-header provenance, single-entrypoint invariant). Each provider owns its Zod config schema (`src/integrations/pm//config-schema.ts`) as the single source of truth — the central `src/config/schema.ts` imports it. PM adapter method signatures use branded `StateId` / `LabelId` / `ContainerId` from `src/pm/ids.ts` to make state-name-vs-ID confusion a compile error at direct-adapter call sites. All runtime surfaces (router, worker, CLI, dashboard) register integrations through a single entrypoint at `src/integrations/entrypoint.ts`. **Spec 010 follow-ups** added generic `pm.discovery.createLabel` / `createCustomField` mutation endpoints + `currentUser` discovery capability + real shared React components for every `StandardStepKind` under `web/src/components/projects/pm-providers/steps/`. **Spec 011** migrated all three production providers (Trello, JIRA, Linear) onto those shared components, added a 7th `StandardStepKind: custom-field-mapping`, widened `container-pick` / `project-scope` / `webhook-url-display` with optional props, and deleted the three legacy `pm-wizard-{trello,jira,linear}-steps.tsx` files. **Spec 012** migrated each provider's webhook UX (programmatic create for Trello/JIRA, signing-secret + instructions for Linear) into per-provider manifest webhook adapters (Fragment compositions around the shared `WebhookUrlDisplayStep`); deleted the legacy `WebhookStep` + `LinearWebhookInfoPanel` + `useWebhookManagement` + `useLinearWebhookInfo`. Every PM wizard step now renders via the manifest path without exception. A new PM provider writes one import in the backend barrel (`src/integrations/pm/index.ts`) and one import in the frontend barrel (`web/src/components/projects/pm-providers/index.ts`); `pm-wizard.tsx`, `pm-wizard-common-steps.tsx`, and `pm-wizard-hooks.ts` receive zero edits. The verification-button readiness path (`areCredentialsReadyFromMetadata` in `pm-wizard-hooks.ts`) and the mutation auth path (`buildProviderAuthArgFromMetadata`) are metadata-driven and require no changes for a new provider. **The shared dashboard state (`pm-wizard-state.ts`) does still require edits**: new providers must add their credential fields to `WizardState` (e.g. `asanaApiKey: string`) and the corresponding action types to `WizardAction`; config-shape hydration belongs on the provider's `ProviderWizardDefinition.buildEditState` — see step 4 of @src/integrations/README.md. Provider-specific hooks, auth metadata, verification display formatting, and UI live inside the provider folder (`kind: 'custom'` steps or Fragment compositions around shared steps). SCM (GitHub) and alerting (Sentry) still use the legacy `IntegrationModule` pattern via self-registration in `src/github/register.ts` + `src/sentry/register.ts`. Don't improvise; the README covers both patterns. - -## PR checkout (worker) — gotcha - -Worker checks out PRs via `refs/pull/N/head` (works for same-repo **and** external-fork branches). When `prNumber` is set on `AgentInput`, `setupRepository`: - -1. Fetches `+refs/pull//head:refs/remotes/pr/` from `origin`. -2. Detached-checks out `pr/`. -3. If `headSha` is also set, verifies `git rev-parse HEAD` matches. - -Any non-zero git exit code **throws** — no warn-and-continue. The legacy `prBranch` field is retained for log readability but **not** used to drive checkout (fork branches don't exist on `origin` and the by-name path silently 404s). - -## Testing - -```bash -npm test # Unit tests (all 4 unit projects) -npm run test:integration # Integration tests (requires Postgres — see below) -npm run test:all # Unit + integration -``` - -**⚠️ Do not use `npm test -- --project integration`** — it _adds_ the integration project on top of the hardcoded unit flags, running all 5 projects. Use `npm run test:integration`. - -**⚠️ Full integration suite takes ~4 min.** When iterating on one file, target it directly: - -```bash -TEST_DATABASE_URL=... npx vitest run --project integration tests/integration/.test.ts -``` - -Integration test DB is auto-discovered in order: `TEST_DATABASE_URL` env → `TEST_DATABASE_URL` in `.cascade/env` → Docker Compose at `127.0.0.1:5433` → `cascade-postgres-test` container IP. If none reachable, integration tests **silently skip**. DB is auto-created if missing. - -Developer machines: `npm run test:db:up` once, then `npm run test:integration`. - -Full test helper/factory/mock catalog: @tests/README.md. - -## Lint + typecheck - -```bash -npm run lint # Check -npm run lint:fix # Fix -npm run typecheck -``` - -## Zod version policy - -**Root and `web/` must use the same Zod major version.** Currently both on `zod@^3.25.0`. `web/tsconfig.json` includes `../src/api/**/*` and `../src/db/**/*` — if majors diverge, `z.infer<>` silently computes different types in backend vs frontend compilation. Bump both workspaces together. - -## Database - -Projects config lives in **PostgreSQL**, not in `config/projects.json`. The JSON file is only used by `npm run db:seed` for initial seeding; it is **not** read at runtime. - -Migrations are **hand-written SQL** in `src/db/migrations/` tracked by drizzle-kit's journal. To add one: - -1. Create `src/db/migrations/NNNN_description.sql`. -2. Add a matching entry to `src/db/migrations/meta/_journal.json` (unique `when` ms, `tag` matches filename without `.sql`). -3. Run `npm run db:migrate`. - -For an existing DB set up via `drizzle-kit push` (no journal), run `npm run db:bootstrap-journal` once. - -## GitHub dual-persona model +| Service | Entry point | Role | +|---|---|---| +| Router | `src/router/index.ts` (:3000) | Receives webhooks, enqueues jobs, spawns one worker container per job | +| Worker | `src/worker-entry.ts` | Processes one job, runs the agent, exits | +| Dashboard | `src/dashboard.ts` (:3001) | tRPC API + web UI (`web/`); the `cascade` CLI talks to it | -Every project needs **two** bot tokens (prevents feedback loops): +Integrations live in `src/integrations/` — PM providers use the manifest registry; GitHub (SCM) and Sentry (alerting) use the legacy `IntegrationModule` pattern. System design: [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md). -- `GITHUB_TOKEN_IMPLEMENTER` — writes code, opens PRs, responds to reviews. -- `GITHUB_TOKEN_REVIEWER` — reviews PRs (used only by the `review` agent). - -Both are **required**. Set via dashboard Credentials tab or: +## Commands ```bash -cascade projects credentials-set --key GITHUB_TOKEN_IMPLEMENTER --value ghp_... -cascade projects credentials-set --key GITHUB_TOKEN_REVIEWER --value ghp_... +npm install && (cd web && npm install) +npm run build # required before running dist/dashboard.js +npm run dev # Router :3000 (needs Redis — `.cascade/setup.sh` installs and starts it) +node --env-file=.env dist/dashboard.js # Dashboard API :3001 +npm run dev:web # Vite frontend :5173 (proxies /trpc + /api to :3001) +npm run dev:all # all three, colour-coded (after a build) + +npm test # unit tests — the 4 unit-* vitest projects +npm run test:fast # unit tests for files changed vs origin/dev (what pre-push runs) +npm run test:integration # needs Postgres: `npm run test:db:up` once (Docker); ~4 min +npm run test:all # unit + integration +npm run verify # lint + typecheck + unit — run before opening a PR +npm run lint / lint:fix / typecheck # Biome, tsc +npm run db:migrate ``` -**Loop-prevention rules (behavioral invariants):** - -- `respond-to-review` fires **only** when the **reviewer** persona submits `changes_requested`. -- `respond-to-pr-comment` skips @mentions from **any** known persona. -- `check-suite-success` checks reviews from the **reviewer** persona specifically. -- All trigger handlers use `isCascadeBot(login)` to filter self-events. **Self-directed exemption:** `review-requested` treats a request where `sender === requested_reviewer` (both a CASCADE persona) as human-initiated and dispatches `review` — the shared-`GITHUB_TOKEN_REVIEWER` contributor re-requesting their own review. Cross-persona `review_requested` events (`sender ≠ requested_reviewer`, e.g. an implementer-authored PR auto-assigning the reviewer) still skip. Safe because CASCADE never programmatically calls GitHub's "request reviewers" API and review *submissions* emit `pull_request_review`, not `review_requested`. - -## Agent update channel - -Each agent type has an optional **`updateChannel`** that gates *where* it posts **communication-only** status updates back to humans. Two independent posting surfaces exist: **PM** (work-item comments on the Trello card / JIRA issue / Linear issue) and **SCM** (comments and reviews on the GitHub PR). The channel catalog, resolver, and posting-matrix helpers are the single source of truth in `src/config/updateChannel.ts`. - -| `updateChannel` | PM posting | SCM posting | -|---|:---:|:---:| -| `none` | ❌ | ❌ | -| `pm-only` | ✅ | ❌ | -| `scm-only` | ❌ | ✅ | -| `both` (default) | ✅ | ✅ | - -**Resolution.** The per-agent override is stored in the `agent_configs.update_channel` column (one row per `(projectId, agentType)`). A `NULL`, absent, or unrecognized value inherits the default **`both`** — the historical "post everywhere" behavior. The config mapper validates the stored string against the channel catalog (`UPDATE_CHANNELS`) and surfaces the per-agent map as `ProjectConfig.agentUpdateChannels`; runtime code reads it via `resolveUpdateChannel(project, agentType)` and branches on `isPmPostingEnabled(channel)` / `isScmPostingEnabled(channel)`. - -**Communication-only, not workflow.** The channel only silences human-facing status chatter; it never blocks an agent from doing real work. **Gated** surfaces: system-driven **acks** (router PR / PM-focused-agent ack comments), **progress** updates (the progress monitor's PM/SCM posters), **lifecycle comments** (the `PR created` fallback plus failure / budget-exceeded / budget-warning / error comments in `PMLifecycleManager`), agent **summaries / reviews** posted back to the work item, and the agent's own **posting tools** — `filterPostingGadgetNames` drops the disabled surface's communication-only gadgets (PM: `PostComment`; SCM: `PostPRComment`, `UpdatePRComment`, `CreatePRReview`, `ReplyToReviewComment`) in **both** the native-tool (`buildExecutionPlan`) and LLMist engine paths, so a `none` / `pm-only` / `scm-only` agent literally cannot call a disabled-surface comment/review tool. **Not gated** (workflow actions always run): PR creation (`CreatePR`), status moves (`MoveWorkItem` and lifecycle `moveOnPrepare` / `moveOnSuccess`), label add/remove, checklist sync (`syncChecklist`), PR linking (`linkPR`), friction reporting (`ReportFriction`), and the "eyes" acknowledgment reaction. - -## Agent triggers - -Trigger format is category-prefixed: `{category}:{event}` -(e.g. `pm:status-changed`, `scm:check-suite-success`, `alerting:issue-alert`). - -Configs live in the `agent_trigger_configs` table. Manage via: - -```bash -cascade projects trigger-discover --agent -cascade projects trigger-list -cascade projects trigger-set --agent --event --enable [--params JSON] -``` - -Some triggers take params (e.g. `review` + `scm:check-suite-success` accepts `{"authorMode":"own"|"external"}`). Legacy configs on `project_integrations.triggers` are auto-migrated on merge to `dev`/`main`. - -**Work-item concurrency lock** — the router prevents duplicate agent runs via a per-agent-type lock on `(projectId, workItemId, agentType)`. Only same-type duplicates are blocked; **different agent types can run concurrently** on the same work item (e.g. review starts while implementation's container is still cleaning up). The lock has a 30-minute TTL hard ceiling that auto-clears stale entries after router restart. - -**Implementation freshness gate** — MNG-1053. PM router adapters intentionally embed a pre-resolved `TriggerResult` for delayed/coalesced PM jobs, so the work-item lock alone cannot prevent a stale implementation snapshot from running. The shared execution pipeline at `src/triggers/shared/agent-execution.ts` now runs a worker-side freshness gate (`src/triggers/shared/implementation-freshness-gate.ts`) before `persistAgentWorkItemLinks()` / `prepareForAgent()`. The gate only fires for `agentType === 'implementation'` with a resolved `workItemId` — review/respond-to-* and follow-up agents bypass it. It reloads live PM work-item state and terminal checklists (`Implementation Steps`, `Acceptance Criteria`), counts active same-type runs, and verifies linked PRs by resolving the implementer GitHub persona token before calling `githubClient.getPR()` (so manual/retry pipeline callers do not depend on ambient GitHub scope). Open or merged PRs and fully-complete terminal checklists block dispatch with a durable `Implementation not started:` PM comment (updating the existing ack comment when present). Checklist read uncertainty always falls into `needs_human_reconciliation`; PR lookup uncertainty does the same when a DB/run-linked PR candidate exists. Closed-unmerged PRs do NOT permanently block reimplementation. - -**Post-completion review dispatch** — when an implementation agent succeeds with a PR, the execution pipeline checks CI status and fires the review agent deterministically (before the container exits). This guarantees review dispatch within seconds of implementation completion, regardless of GitHub webhook timing. Uses the same `claimReviewDispatch` dedup key as the `check-suite-success` trigger, so the two paths cannot double-enqueue. - -**Deferred re-check** — a trigger handler can return `TriggerResult.deferredRecheck: { delayMs, coalesceKey, recheckKind? }` (with `agentType: null`) to schedule a bare delayed job via `scheduleCoalescedJob`. The router scheduling is adapter-agnostic, but **bare re-dispatch is currently GitHub-only**: `GitHubRouterAdapter.buildJob()` strips `triggerResult` from the job so the GitHub worker re-dispatches through the trigger registry for fresh provider state. Non-GitHub adapters (Trello, JIRA, Linear, Sentry) embed `triggerResult` in the job; their workers pass it to `resolveTriggerResult()`, which returns the pre-resolved `agentType: null` result without re-dispatching — a non-GitHub handler using this field would schedule a job that reuses the same result rather than re-evaluating provider state. There are two recheck kinds, controlled by the optional `recheckKind` field on `deferredRecheck`: **mergeability re-check** (no `recheckKind`, sets `mergeabilityRecheckAttempt: 1` on the job) — one-shot; if the re-check still cannot resolve state, the worker Sentry-captures under `mergeability_recheck_exhausted` and stops without re-queueing. **Check-suite re-check** (`recheckKind: 'check-suite'`, sets `checkSuiteRecheckAttempt: 1` on the job) — safe rescheduling; if the Actions API is still stale when the job fires, the worker reschedules another coalesced delayed job instead of exhausting, so review/respond-to-ci dispatch stays alive until the API catches up. Used by `check-suite-success` and `check-suite-failure` handlers for the Actions-API-lag case (ucho PR #394/MNG-683, 2026-05-11). - -**Worker exit diagnostics** — when a worker container exits non-zero, the router calls `container.inspect()` *before* AutoRemove reaps it and stamps the run record's `error` field with a structured, grep-stable string: `Worker crashed with exit code N · OOMKilled= · reason=""`. The `OOMKilled=true` marker is the definitive cgroup-OOM signal (per Docker's own `State.OOMKilled`); a 137 exit *without* `OOMKilled=true` means the kill came from inside the container or from a non-cgroup signal — *not* memory. The `[WorkerManager] Resolved spawn settings` log emitted at every spawn includes both `projectWatchdogTimeoutMs` and `globalWorkerTimeoutMs` so post-mortems can confirm whether the per-project override actually won. See `src/router/active-workers.ts:formatCrashReason` for the format and `tests/unit/router/container-manager-diagnostics.test.ts` for regression pins. +## Gotchas -**Friction reporting** — agents with `pm:friction` can call `cascade-tools pm report-friction` / `ReportFriction` for incidental tooling, environment, permission, dependency, test, PM-data, or SCM-data papercuts. Configure the optional Friction slot in the PM wizard's Status Mapping step: Trello uses `lists.friction`; JIRA and Linear use `statuses.friction`. The feature does not add provider adapter methods or a DB-backed friction index — it materializes a normal PM work item through existing `createWorkItem` plus optional `moveWorkItem`. Reports are first written to `CASCADE_FRICTION_SIDECAR_PATH` as a JSONL outbox, then filed immediately when possible; backend drain retries pending reports after the engine returns, including ordinary failures. Missing friction slot returns a non-fatal `friction_slot_missing`/`queued_slot_missing` result, and drain failures log/capture Sentry under `friction_sidecar_drain_failed` without failing an otherwise successful run. +- `npm start` runs the **router** (`dist/router/index.js`), not the dashboard. +- Never `npm test -- --project integration` — it *adds* the integration project to the hardcoded unit flags and runs all five. Use `npm run test:integration`. +- One integration file: `TEST_DATABASE_URL=… npx vitest run --project integration tests/integration/.test.ts`. If no test database is reachable, integration tests **silently skip**. +- Git hooks (`lefthook.yml`): pre-commit = Biome `--write` on staged `ts/tsx/js/jsx` + `tsc --noEmit` + the auth-header-provenance test; commit-msg = commitlint (Conventional Commits); pre-push = `npm run test:fast` (changed files only — run `npm test` yourself before a PR). +- Tests failing with connection errors → run `.cascade/ensure-services.sh` to bring Postgres/Redis back. -**Dispatch failure semantics** — spec 015 (verified live in prod via the ucho/MNG-350 incident on 2026-04-26): +## Hard invariants -- **Capacity miss waits, never throws.** When the dispatcher pulls a job and the worker pool is at `maxWorkers`, it `await`s a slot via the in-process slot-waiter (default `slotWaitTimeoutMs` = 5min). The slot is conceptually held by the running container — `slotReleased()` is called once per cleanup from `cleanupWorker`, never from the dispatcher. -- **Transient Docker errors retry.** `ECONNREFUSED` / `ECONNRESET` / `ENOTFOUND` on the Docker socket, registry HTTP 429, container-name 409 collisions, and the `SLOT_WAIT_TIMEOUT` itself all classify as transient and propagate unchanged so BullMQ retries via `attempts: 4` + `backoff: { type: 'exponential', delay: 5000 }` (~75s total before exhaustion). Both `cascade-jobs` and `cascade-dashboard-jobs` use the same retry config. -- **Terminal errors fail fast.** `TypeError` / `ZodError` (validation) and image-not-found *after* fallback exhaustion are wrapped in BullMQ's `UnrecoverableError`, which skips the retry budget entirely. -- **Failed-event compensation releases locks.** Every dispatch failure (transient retry exhaustion, terminal error, slot-wait timeout exhaustion) flows through `worker.on('failed')`, which calls `releaseLocksForFailedJob` to release the work-item lock, agent-type counter, and recently-dispatched dedup mark. Without this, the locks leak for ~30min and silently reject every follow-up webhook for the same trio. -- **Webhook decision reasons are three-way.** When the work-item lock check rejects a webhook, the message distinguishes: - - `Job queued: ...` (success — not a lock rejection) - - `Awaiting worker slot: ...` (lock held + dispatch in flight — healthy) - - `Work item locked (no active dispatch): ...` (wedged-lock canary — the lock-state classifier could correlate the lock count with neither an active worker nor a queued/waiting BullMQ job; this fires a Sentry capture tagged `wedged_lock_canary` so any regression in compensation is loud) - -The wedged-lock canary should never fire under normal operation. Its presence in webhook logs or Sentry is itself a regression invariant: a code path acquired a lock without registering its compensation. - -## Review agent — context shape (debugging) - -Review agent receives a **compact per-file diff context**, not full file contents. Each changed file is a `### (, +N -M)` section with a unified diff hunk. Budget: `REVIEW_DIFF_CONTEXT_TOKEN_LIMIT` = 200k tokens, per-file cap 10%. - -GitHub's changed-file API is used for file enumeration and change counts, but compact patch bodies come from the checked-out PR workspace via `git diff origin/...HEAD`. Files that can't fit or can't be locally verified (deleted, binary/no text patch, local diff failure/empty patch, oversized patch, or budget exhausted) are injected as `SKIPPED FILES` with instructions to fetch on demand via `cascade-tools scm get-pr-diff --prNumber --path `, `Read`, or `Grep`. Large or one-line JSON diffs that would truncate stdout should add `--outputFile /tmp/pr-diff.md` — the CLI writes the full multiline Markdown payload to disk and returns a compact `{outputFile, fileCount, bytes, pathFilter}` summary on stdout (MNG-1059 / MNG-1045). - -When review output misses something, check the `PR context prepared` log entry for `included` / `skipped` / `skipReasons`, `patchSources`, `totalDiffTokens`, `perFileTokenCap`, and `localGitMismatches` to confirm whether the file was visible to the agent and whether GitHub's API patch differed from the local patch. Also check context offload logs if the diff context was written under `.cascade/context/`. - -CI check status is **informational, not fatal** (MNG-1750): the `fetchPRContextStep` boot step wraps only `getCheckSuiteStatus` in a try/catch. If the reviewer PAT lacks the **Actions: Read** permission the Actions API throws 403, and the `GetPRChecks` context injection degrades to an explicit "CI check status UNAVAILABLE" message (with the permission hint, deliberately distinct from "No CI checks configured") plus a `WARN CI check status unavailable` log — instead of killing the agent boot with a `BootFailureError`. PR details (`getPR`) and the diff (`getPRDiff`) stay fatal — a review without the PR itself is meaningless. - -**cascade-tools shell-safety contract** — MNG-1059. cascade-tools commands that accept markdown/multiline payloads (`--body`, `--text`, `--description`, `--details`, `--comments`) declare a `--*-file ` companion via `cli.fileInputAlternatives`. Agents are instructed in the system prompt to prefer the file form when content contains backticks, code fences, `$(...)`, or newlines — shells expand those tokens even inside single quotes once they layer through `bash -c`, and newlines break argv parsing. The shared CLI factory at `src/gadgets/shared/cli/params.ts:rejectMultipleStdinConsumers` enforces the single-stdin-consumer invariant: only one `--*-file -` per command. Passing two stdin consumers (e.g. `--body-file - --comments-file -`) returns a structured `flag-parse` envelope with `error.flag: "body-file,comments-file"` and a hint to write one payload to a temp file — *before* any `readFileSync(0, ...)` call. The native-tool system prompt also renders a "cascade-tools shell-safety rules" section with safe heredoc / temp-file patterns. Prompt example rendering suppresses inline `--body '...'` examples for shell-sensitive content (backticks / `$(...)` / newlines) when a file-input companion exists, redirecting agents at the safer `--*-file ` form. - -## Engines - -Default engine: `claude-code`. Alternatives: `codex`, `opencode`. - -```bash -cascade projects update --agent-engine claude-code -# per-agent override: -cascade agents create --agent-type implementation --project-id --engine codex -``` - -Auth: - -- **Claude Code subscription**: `CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...` (from `claude setup-token`). CASCADE writes `~/.claude.json` before run. -- **Codex subscription**: store `CODEX_AUTH_JSON` credential (contents of `~/.codex/auth.json` after `codex login`). CASCADE persists refreshed tokens back to the DB after each run. -- **API-key providers**: store `OPENAI_API_KEY` / other keys as project credentials. +- Project config lives **only** in Postgres (`projects`, `project_integrations`, `agent_configs`, `agent_trigger_configs`); project secrets live in `project_credentials`. `config/projects.json` is seed data for `npm run db:seed`, never read at runtime. Never add an env-var fallback for a project-scoped secret. +- Every project has **two** GitHub personas — `GITHUB_TOKEN_IMPLEMENTER` (writes code, opens PRs) and `GITHUB_TOKEN_REVIEWER` (reviews). Both required. Every SCM trigger handler filters self-events with `isCascadeBot(login)`; the loop-prevention rules are in [10-resilience](./docs/architecture/10-resilience.md). +- Trigger events are `{category}:{event}` (`pm:status-changed`, `scm:check-suite-success`, `alerting:issue-alert`); per-project enablement lives in `agent_trigger_configs` (`cascade projects trigger-list` / `trigger-set`). +- Schema changes are hand-written SQL in `src/db/migrations/NNNN_description.sql` plus a `meta/_journal.json` entry (unique `when`, `tag` = filename without `.sql`), applied with `npm run db:migrate`. Never edit an applied migration; never `drizzle-kit push` against a shared database. +- Every runtime surface registers integrations through `src/integrations/entrypoint.ts` once (guard: `tests/unit/integrations/entrypoint-usage.test.ts`). A new PM provider is one manifest + one import in each barrel — follow [`src/integrations/README.md`](./src/integrations/README.md), don't improvise. +- Root and `web/` must share one Zod major (`web/tsconfig.json` compiles `../src/api` and `../src/db`; diverging majors make `z.infer<>` disagree silently). Bump both together (guard: `tests/unit/repo-hygiene.test.ts`). ## Environment -Required: - -- `DATABASE_URL` — PostgreSQL connection string. -- `REDIS_URL` — BullMQ queue, defaults to `redis://localhost:6379`. - -Optional: - -- `DATABASE_SSL` — `false` disables SSL (local dev); `no-verify` keeps TLS but skips certificate verification — required for managed Postgres that requires TLS yet presents a self-signed/private-CA cert (e.g. Supabase's connection pooler), where `DATABASE_CA_CERT` can't help because spawned worker containers get `DATABASE_*` env but no mounted cert file; unset → TLS with verification. `DATABASE_CA_CERT` pins a CA for managed DBs with a private CA (verification mode only). -- `CREDENTIAL_MASTER_KEY` — 64-char hex (AES-256 key) to encrypt project credentials at rest. Without it, credentials are stored as plaintext; both modes coexist. -- `GITHUB_WEBHOOK_SECRET` — opt-in HMAC verification; store as the `webhook_secret` role on the GitHub SCM integration. -- `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`, `SENTRY_TRACES_SAMPLE_RATE` — observability. -- `PM_COALESCE_WINDOW_MS` — settle window (ms) for BullMQ delayed-job coalescing on `pm:status-changed` events. Any dispatch for the same `${projectId}:${workItemId}` within the window supersedes the prior pending dispatch, across agent types. Ack comment is deferred to job fire time to avoid orphaned comments on supersede. Defaults to `10000` (10 s); `0` disables. Fixes JIRA's double-fire when an issue is created in a non-default workflow column. The legacy name `PM_CREATE_COALESCE_WINDOW_MS` is still accepted as a fallback. - -**Project credentials (GitHub tokens, Trello/JIRA/Linear keys, LLM API keys) live in the `project_credentials` table.** The DB is the **sole source of truth** — there is no env var fallback for project-scoped secrets. +Required: `DATABASE_URL`, `REDIS_URL`. Every other variable — `DATABASE_SSL` modes, `CREDENTIAL_MASTER_KEY`, `SENTRY_*`, `PM_COALESCE_WINDOW_MS` — is catalogued with its semantics in `.env.example`; add new ones there, not here. -## JIRA scoped tokens +## Before you touch an area, read -JIRA supports classic site tokens **and** Atlassian API tokens with scopes. The optional `authType` field on the JIRA integration config (`'basic' | 'scoped'`, default `'basic'`) is a **non-secret connection setting** (mirrors `baseUrl`, not a credential role) that selects the REST v3 host — **both modes authenticate with HTTP Basic (`email:api_token`)**, so `authType` picks the host, not the auth scheme. Every REST v3 call site routes through the shared resolver `resolveJiraApiBaseUrl(creds)` (`src/jira/api-host.ts`): `basic`/absent keeps the tenant **site URL**; `scoped` routes through the Atlassian **gateway** `https://api.atlassian.com/ex/jira/{cloudId}`, where `cloudId` is resolved from `${baseUrl}/_edge/tenant_info` (always the site URL, never the gateway) and cached per `baseUrl`. The worker carries the mode across process boundaries via `CASCADE_JIRA_AUTH_TYPE`. **Required scopes:** read/write Jira work, plus `manage:jira-webhook` (or granular `write:webhook:jira` + `read:field:jira` + `read:project:jira`) for programmatic `/rest/api/3/webhook` management — a scoped token lacking them gets `401`/`403`, and operators should register the webhook manually. **Known limitation:** ack reactions are unavailable under scoped tokens (`/rest/reactions/1.0/` is not exposed on the gateway), so the reaction degrades to a skipped no-op; `accessible-resources` is intentionally not used for cloudId (it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens). +Nothing loads these automatically — open the area doc before editing in that part of the tree. -## JIRA status matching (locale-invariant) +| Working in | Read first | Then | +|---|---|---| +| `src/integrations/**`, `src/pm/**`, `src/{jira,linear,trello}/**`, `web/**/pm-providers/**`, `web/**/pm-wizard*` | [`docs/areas/pm-integrations.md`](./docs/areas/pm-integrations.md) | [`src/integrations/README.md`](./src/integrations/README.md) | +| `src/router/**`, `src/triggers/**`, `src/webhook/**`, `src/queue/**` | [`docs/areas/router-dispatch.md`](./docs/areas/router-dispatch.md) | [10-resilience](./docs/architecture/10-resilience.md), [`src/triggers/README.md`](./src/triggers/README.md) | +| `src/agents/**`, prompts, review context | [`docs/areas/agents.md`](./docs/areas/agents.md) | [04-agent-system](./docs/architecture/04-agent-system.md), [03-trigger-system](./docs/architecture/03-trigger-system.md) | +| `src/backends/**` | [`docs/areas/backends.md`](./docs/areas/backends.md) | [`docs/adding-engines.md`](./docs/adding-engines.md) | +| `src/gadgets/**`, `src/cli/**` (`cascade-tools`) | [`src/gadgets/README.md`](./src/gadgets/README.md) | [07-gadgets](./docs/architecture/07-gadgets.md) | +| `tests/**` | [`tests/README.md`](./tests/README.md) | | +| `src/db/**` | [09-database](./docs/architecture/09-database.md) | | +| A target repo's `.cascade/` hooks | [`docs/cascade-directory.md`](./docs/cascade-directory.md) | | +| Setup, credentials, operations | [`docs/getting-started.md`](./docs/getting-started.md) | | -JIRA status matching is **ID-based**, not name-based (MNG-1768). JIRA status *names* render in the language of whichever account a request is scoped to, so name-on-both-ends matching silently no-op'd status moves when the credential account's language differed from the site language. Both ends now match on the locale-invariant JIRA status **ID** — the dispatch trigger (`JiraStatusChangedTrigger`) reads `changelog.items[].to` / `issue.fields.status.id` and resolves via `resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions`; `moveWorkItem` matches `transitions[].to.id` first — with case-insensitive **name matching kept as a fallback** so existing name-based configs keep working (zero forced migration). The wizard persists status IDs going forward and auto-upgrades legacy name-valued mappings → IDs when project details load. A genuine no-transition-found miss emits a Sentry `captureException` tagged `jira_transition_not_found` instead of a silent WARN. See @src/integrations/README.md for the full contract. +## Keeping this file small -## Git hooks +This file is loaded into every Claude Code session and injected into CASCADE runs whose context pipeline includes `contextFiles`. CI (`tests/unit/architecture-docs.test.ts`) fails it past 200 lines or half the worker inline budget, and rejects `@` imports — `readContextFiles` uses its raw contents. -Lefthook runs pre-commit (lint, typecheck) and pre-push (unit + integration tests) hooks automatically. Pre-push auto-starts an ephemeral Postgres via `npm run test:db:up` — Docker must be running. +- Universal command, gotcha or invariant → here. +- Path-scoped gotcha → `docs/areas/.md` (≤ 60 lines; link the reference doc, don't restate it). +- How something works → `docs/architecture/`; provider/gadget/test contracts → the in-tree READMEs; operator how-to → `docs/getting-started.md`; env vars → `.env.example`. +- History goes to `CHANGELOG.md`. Never add ticket IDs, spec numbers, dates or incident narrative here or in area docs (CI-checked). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dddbfd7e..d96a805f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,7 @@ docs: update self-hosting guide refactor(agents): extract shared prompt builder ``` -This is enforced by commitlint via lefthook pre-commit hooks. +This is enforced by commitlint via the lefthook `commit-msg` hook. ## Pull Request Workflow @@ -117,7 +117,7 @@ This is enforced by commitlint via lefthook pre-commit hooks. ## Project Structure -See [CLAUDE.md](./CLAUDE.md) for a detailed architecture overview. Key directories: +See [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) for a detailed architecture overview. Key directories: - `src/router/` — Webhook receiver (enqueues jobs to Redis) - `src/triggers/` — Event handlers (Trello, JIRA, Linear, GitHub) diff --git a/README.md b/README.md index a8085ad1..7207c919 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ cascade projects update --rebuild-worker-image # rebuild against a ref cascade projects update --clear-dockerfile # revert to the global default ``` -For deeper documentation on all of these topics, see [CLAUDE.md](./CLAUDE.md). +For deeper documentation on all of these topics, see [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) and [Getting Started](./docs/getting-started.md). --- diff --git a/SECURITY.md b/SECURITY.md index 43993d7b..473112cf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,7 @@ Please include: Cascade incorporates several security measures: -- **Credential encryption at rest**: AES-256-GCM encryption for all stored credentials when `CREDENTIAL_MASTER_KEY` is configured. See [CLAUDE.md](./CLAUDE.md#credential-encryption-at-rest) for details. +- **Credential encryption at rest**: AES-256-GCM encryption for all stored credentials when `CREDENTIAL_MASTER_KEY` is configured. See [Configuration and Credentials](./docs/architecture/08-config-credentials.md#credential-encryption) for details. - **Dual-persona model**: Separate GitHub bot accounts for implementation and review prevent self-approval and feedback loops. - **No env var fallback for secrets**: All project credentials are stored in the database — no secrets in environment variables or config files. - **Session-based auth**: HTTP-only cookies with bcrypt password hashing for dashboard access. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c5207531..5bb0916e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ CASCADE is a PM-to-Code automation platform that connects project management tools (Trello, JIRA, Linear), source control (GitHub), and monitoring (Sentry) to AI-powered agents that autonomously implement features, review PRs, debug failures, and manage backlogs. Webhooks from external providers flow through a router, get queued in Redis, and are processed by ephemeral worker containers that run agents against cloned repositories. -> **Relationship to CLAUDE.md**: `CLAUDE.md` is the operational reference (commands, env vars, how-to). This document and its deep-dives cover the *system design* — how components fit together and why. +> **Relationship to CLAUDE.md**: `CLAUDE.md` is the short entry point loaded by Claude Code and injected through CASCADE's `contextFiles` context step — commands, gotchas, hard invariants and a pointer table. `docs/areas/` holds per-area working rules (imperatives plus links, read before editing that part of the tree). This document and its deep-dives cover the *system design* — how components fit together and why. ## System Overview @@ -126,6 +126,7 @@ sequenceDiagram | `src/utils/` | Logging, repo cloning, lifecycle/watchdog, env scrubbing | | `src/types/` | Shared TypeScript types | | `src/queue/` | BullMQ queue helpers | +| `docs/areas/` | Per-area working rules for contributors and agents (pm-integrations, router-dispatch, agents, backends) | ## Deep-Dive Documents diff --git a/docs/architecture/01-services.md b/docs/architecture/01-services.md index ebdb31cc..b75ac46b 100644 --- a/docs/architecture/01-services.md +++ b/docs/architecture/01-services.md @@ -139,6 +139,10 @@ The security scrub in step 8 prevents agent engines (which execute arbitrary LLM - **Webhook jobs** (`trello`, `github`, `jira`, `linear`, `sentry`) — call the provider-specific webhook processor, which re-runs trigger dispatch and executes the matched agent - **Dashboard jobs** (`manual-run`, `retry-run`, `debug-analysis`) — call `processDashboardJob()`, which loads project config and invokes the appropriate runner +### Repository checkout + +Workers clone the target repository at runtime (`src/agents/shared/repository.ts` → `src/utils/repo.ts`); nothing from the host is mounted. When the job carries a `prNumber`, `setupRepository` fetches `+refs/pull//head:refs/remotes/pr/` from `origin`, checks out `pr/` detached, and — when `headSha` is also set — verifies that `git rev-parse HEAD` matches. This works for same-repo and external-fork PRs alike; the legacy `prBranch` field is kept for log readability but does not drive checkout, because fork branches do not exist on `origin` and a by-name checkout silently 404s. Any non-zero git exit throws — there is no warn-and-continue. + ## Dashboard **Entry point**: `src/dashboard.ts` diff --git a/docs/architecture/03-trigger-system.md b/docs/architecture/03-trigger-system.md index 2b11ab15..bba1bc75 100644 --- a/docs/architecture/03-trigger-system.md +++ b/docs/architecture/03-trigger-system.md @@ -219,6 +219,14 @@ Each trigger in a YAML agent definition can declare a `contextPipeline` — an o | `pipelineSnapshot` | Fetch PM workflow/pipeline state and emit the single authoritative `PipelineSnapshotSummary` JSON context for backlog-manager | | `alertingIssue` | Fetch Sentry issue and event details | +### prContext budget and debugging + +The review agent receives a compact per-file diff context, not full file contents. `REVIEW_DIFF_CONTEXT_TOKEN_LIMIT` (`src/config/reviewConfig.ts`, 200k tokens) caps the whole context and each file gets at most 10 % of it. GitHub's changed-file API supplies the file list and change counts; patch bodies come from the checked-out workspace via `git diff origin/...HEAD`. Files that cannot fit or cannot be locally verified (deleted, binary/no text patch, local diff failure or empty patch, oversized patch, budget exhausted) are listed under `SKIPPED FILES` with instructions to fetch on demand via `cascade-tools scm get-pr-diff --prNumber --path ` (add `--outputFile ` for large or one-line JSON diffs that would truncate stdout), `Read`, or `Grep`. + +When a review misses something, check the `PR context prepared` log entry: `included` / `skipped` / `skipReasons`, `patchSources`, `totalDiffTokens`, `perFileTokenCap`, and `localGitMismatches` (GitHub's API patch differed from the local one). Also check the context-offload logs if the diff was written under `.cascade/context/`. + +CI check status is informational, not fatal: `fetchPRContextStep` wraps only `getCheckSuiteStatus` in a try/catch. If the reviewer PAT lacks the **Actions: Read** permission, the Actions API returns 403 and the `GetPRChecks` injection degrades to an explicit "CI check status UNAVAILABLE" message (deliberately distinct from "No CI checks configured") plus a `WARN CI check status unavailable` log. `getPR` and `getPRDiff` stay fatal — a review without the PR itself is meaningless. + ## Shared Agent Execution `src/triggers/shared/agent-execution.ts` @@ -249,7 +257,7 @@ This includes: - Work-item and PR traceability in `agent-work-items.ts`: create/update work-item records, maintain PR/work-item links before and after execution, fetch PR titles, and backfill run PR numbers. - Agent execution in `agent-execution-runtime.ts`: call `runAgent()` with the resolved input plus project, config, and remaining budget. - Post-run PM behavior in `agent-pm-summary.ts` and `agent-execution-lifecycle.ts`: post review/output summaries to the PM work item, handle artifacts, post budget warnings, clean up processing state, and call `handleSuccess` or `handleFailure`. -- Follow-up dispatch in `agent-execution-followups.ts`: dispatch review after a successful implementation PR once CI is passing and the review dedup key is claimed, and chain backlog-manager after a successful splitting run when the auto label/capacity checks allow it. +- Follow-up dispatch in `agent-execution-followups.ts`: dispatch review after a successful implementation PR once CI is passing and the review dedup key is claimed (`claimReviewDispatch` — the same key the `check-suite-success` trigger uses, so the two paths cannot double-enqueue; this fires before the container exits, so review dispatch does not depend on GitHub webhook timing), and chain backlog-manager after a successful splitting run when the auto label/capacity checks allow it. - Auto-debug in `agent-auto-debug.ts`: fire-and-forget debug analysis for eligible failed or timed-out runs after callbacks and follow-up dispatch complete. It calls the shared `triggerDebugAnalysis()` runner, whose running/failed lifecycle is durable and cross-process — see [Debug-analysis status](#debug-analysis-status-durable-cross-process) below. Credential scoping still happens before the facade runs. PM webhook handling enters provider credentials and PM provider scope before dispatch; GitHub and Sentry use `webhook-execution.ts` / `credential-scope.ts` to inject LLM keys, PM credentials, PM provider scope, and GitHub persona tokens as needed. diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md index 33880311..586b9248 100644 --- a/docs/architecture/08-config-credentials.md +++ b/docs/architecture/08-config-credentials.md @@ -237,20 +237,7 @@ await withLinearCredentials({ apiKey }, async () => { `src/jira/api-host.ts`, `src/jira/authType.ts` -JIRA supports classic unscoped site tokens **and** Atlassian API tokens with scopes. The mode is selected by the optional `authType` field on the JIRA integration config (`project_integrations.config`) — a non-secret connection setting that mirrors `baseUrl`, **not** a credential role. Values: `'basic'` (or absent) and `'scoped'`. Both modes authenticate with **HTTP Basic** (`email:api_token`); `authType` selects the REST v3 *host*, not the auth scheme. - -Every REST v3 call site routes through one shared resolver, `resolveJiraApiBaseUrl(creds)` — the JIRA analogue of the shared auth-header helper: - -| `authType` | REST v3 host | Notes | -|---|---|---| -| `basic` / absent | tenant **site URL** (`creds.baseUrl`, e.g. `https://acme.atlassian.net`) | Classic behavior, unchanged. Every pre-existing config maps here. | -| `scoped` | Atlassian **gateway** (`https://api.atlassian.com/ex/jira/{cloudId}`) | `cloudId` is resolved from `${baseUrl}/_edge/tenant_info` (always the site URL, never the gateway) with the same Basic scoped token, cached per `baseUrl`. Direct site REST v3 calls can fail under scoped tokens, so the gateway is the supported path. | - -The worker/CLI credential scope carries the mode across process boundaries via the `CASCADE_JIRA_AUTH_TYPE` env var (injected by `secretBuilder.augmentProjectSecrets`); `normalizeJiraAuthType` maps absent/unknown values back to `'basic'` so existing projects keep working. `accessible-resources` is intentionally **not** used to discover `cloudId` — it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens. - -**Required scopes.** Read/write Jira work (classic OAuth `read:jira-work` + `write:jira-work`). Programmatic webhook management additionally needs webhook scopes — classic OAuth `manage:jira-webhook`, or granular `read:field:jira` + `read:project:jira` + `write:webhook:jira`. A scoped token without webhook scopes (or a non-app caller) gets `401`/`403` from `/rest/api/3/webhook`; the wizard then surfaces an actionable message pointing at manual webhook registration. - -**Known limitation — ack reactions.** The "eyes" acknowledgment reaction uses Jira's internal `/rest/reactions/1.0/` API, which lives only on the tenant site URL and is not confirmed on the scoped gateway. Under `scoped` auth the reaction degrades quietly (one log line, then skip) — it is best-effort and never fails a run. Comments, status transitions, and label writes are unaffected. +JIRA's optional `authType` (`'basic'` | `'scoped'`, default `'basic'`) is a **non-secret connection setting** on the integration config, not a credential role; both modes authenticate with HTTP Basic and `authType` only selects the REST v3 host. The worker/CLI credential scope carries it across process boundaries as `CASCADE_JIRA_AUTH_TYPE` (injected by `secretBuilder.augmentProjectSecrets`), and `normalizeJiraAuthType` maps absent/unknown values to `'basic'`. The host-resolution contract (`resolveJiraApiBaseUrl`, cloudId lookup, gateway routing) is specified once in [`src/integrations/README.md`](../../src/integrations/README.md#jira-authentication-modes-scoped-tokens); required scopes and known limitations for operators are in [Getting Started](../getting-started.md#scoped-api-tokens-authtype). ## Credential Encryption diff --git a/docs/architecture/10-resilience.md b/docs/architecture/10-resilience.md index 9b626cd2..46dbf77e 100644 --- a/docs/architecture/10-resilience.md +++ b/docs/architecture/10-resilience.md @@ -92,7 +92,7 @@ The backend adapter drains pending sidecar events after the engine returns, incl The router queues `cascade-jobs` and `cascade-dashboard-jobs` with `attempts: 4` and exponential backoff. Dispatch errors before a worker container starts are classified in `src/router/dispatch-error-classifier.ts`: - Transient: Docker socket `ECONNREFUSED` / `ECONNRESET` / `ENOTFOUND`, registry HTTP 429, container-name HTTP 409, and `SLOT_WAIT_TIMEOUT`. -- Terminal: validation errors (`TypeError`, `ZodError`) and image-not-found after fallback exhaustion. +- Terminal: validation errors (`TypeError`, `ZodError`) and image-not-found after fallback exhaustion. These are wrapped in BullMQ's `UnrecoverableError`, which skips the retry budget entirely. Post-enqueue dispatch failures (Docker socket errors, slot-wait timeouts, container failures) flow through the BullMQ `failed` event and call `releaseLocksForFailedJob`, releasing the work-item lock, agent-type counter, and recently-dispatched mark. Webhook logs distinguish healthy backpressure (`Awaiting worker slot`) from the wedged-lock canary (`Work item locked (no active dispatch)`). Enqueue/schedule failures that occur before a BullMQ job exists are handled differently — see the split below. @@ -168,6 +168,8 @@ Both GitHub persona usernames (implementer + reviewer) are resolved and cached. - `respond-to-review` only fires when the **reviewer** persona submits `changes_requested` - `respond-to-pr-comment` skips @mentions from **any** known persona +- `check-suite-success` looks for an approving review from the **reviewer** persona specifically +- Every SCM trigger handler filters self-events with `isCascadeBot(login)`; the one deliberate exemption is the self-directed `review_requested` case described in [03-trigger-system](./03-trigger-system.md) - Trello/JIRA handlers check their bot member/account IDs similarly ### Self-authored event filtering @@ -192,7 +194,7 @@ See [08-config-credentials](./08-config-credentials.md) — AES-256-GCM encrypti Periodic scan for Docker containers that outlived their expected lifetime (watchdog timeout + buffer). Orphans are killed and their run records marked as failed. -When a worker container exits non-zero, the router inspects it before Docker AutoRemove can reap it and writes a grep-stable error reason: `Worker crashed with exit code N · OOMKilled= · reason=""`. `OOMKilled=true` is the definitive cgroup OOM signal; exit 137 without that marker means something else sent the signal. +When a worker container exits non-zero, the router inspects it before Docker AutoRemove can reap it and writes a grep-stable error reason: `Worker crashed with exit code N · OOMKilled= · reason=""`. `OOMKilled=true` is the definitive cgroup OOM signal; exit 137 without that marker means something else sent the signal. The string is produced by `formatCrashReason` in `src/router/active-workers.ts` and pinned by `tests/unit/router/container-manager-diagnostics.test.ts` — keep it grep-stable. The `[WorkerManager] Resolved spawn settings` log line emitted at every spawn records both `projectWatchdogTimeoutMs` and `globalWorkerTimeoutMs`, so a post-mortem can confirm whether the per-project override actually won. ## Worker Lifecycle Internals diff --git a/docs/areas/agents.md b/docs/areas/agents.md new file mode 100644 index 00000000..43449b16 --- /dev/null +++ b/docs/areas/agents.md @@ -0,0 +1,22 @@ +# Agents, prompts and context + +**Applies to:** `src/agents/**`, `src/worker-entry.ts`, `src/config/reviewConfig.ts`, `src/config/claudeCodeConfig.ts`, `src/config/updateChannel.ts` + +Mechanism lives in [04-agent-system](../architecture/04-agent-system.md) and [03-trigger-system](../architecture/03-trigger-system.md). + +## Prompts + +- Prompt templates and partials live in `src/agents/prompts/templates/`; the `prompt_partials` table shadows disk (DB first, disk fallback — `src/agents/prompts/index.ts`). After editing a partial run `npm run db:seed-prompts`, or workers keep using the old copy. +- `tests/unit/agents/prompts.test.ts` pins phrases the templates must keep; run it after any template change. + +## Context injection + +- Context injections are inlined only while under `CONTEXT_OFFLOAD_CONFIG.inlineThreshold` (`src/config/claudeCodeConfig.ts`); larger ones are written to `.cascade/context/` for on-demand reads (`src/backends/shared/contextFiles.ts`). The target repo's `CLAUDE.md` / `AGENTS.md` are `cat`-injected by `readContextFiles` (`src/agents/utils/setup.ts`) — `@` imports are not expanded, so keep those files small. +- In `fetchPRContextStep` keep `getPR` / `getPRDiff` fatal and `getCheckSuiteStatus` non-fatal → 03-trigger-system § prContext budget and debugging. +- The review diff budget is `REVIEW_DIFF_CONTEXT_TOKEN_LIMIT` with a 10 % per-file cap; when a reviewer "missed" a file, read the `PR context prepared` log before touching budgets → 03-trigger-system § prContext budget and debugging. + +## Behaviour gates + +- `updateChannel` gates communication-only posting (acks, progress, summaries, comment/review tools); never gate workflow actions (PR creation, status moves, labels, checklists) behind it → 04-agent-system § Update Channel. +- Communication-only gadgets are removed by `filterPostingGadgetNames` in both the native-tool and LLMist paths; a new posting tool must be added to that list → 04-agent-system § Update Channel. +- Repository checkout uses `refs/pull//head` (`src/agents/shared/repository.ts`); do not reintroduce branch-name checkout → [01-services § Repository checkout](../architecture/01-services.md). diff --git a/docs/areas/backends.md b/docs/areas/backends.md new file mode 100644 index 00000000..eec859fa --- /dev/null +++ b/docs/areas/backends.md @@ -0,0 +1,11 @@ +# Engine backends + +**Applies to:** `src/backends/**` + +Mechanism lives in [05-engine-backends](../architecture/05-engine-backends.md) and [`src/backends/README.md`](../../src/backends/README.md); the end-to-end recipe for a new engine is [adding-engines](../adding-engines.md). + +- Follow the `adding-engines` checklist end to end — the engine-contract and env-filter tests are required, not optional. +- Engines deliberately suppress native instruction-file discovery (`--ignore-user-config --ignore-rules` for Codex, `instructions: []` for OpenCode, a plain `systemPrompt` string with no `settingSources` for the Claude Agent SDK). The repo's `CLAUDE.md` reaches agents only through `readContextFiles` → [agents](./agents.md). Do not re-enable discovery without measuring the context cost. +- Secrets reach engine subprocesses only through `secretBuilder` / `secretOrchestrator` and the env allowlist in `src/backends/shared/envFilter.ts`; never pass `process.env` through. +- Subscription auth files (`~/.claude.json`, `~/.codex/auth.json`) are written per run and cleaned up in `afterExecute`; Codex token refreshes are persisted back to the `CODEX_AUTH_JSON` credential. +- For native-tool engines, the system prompt is `NATIVE_TOOL_EXECUTION_RULES` + agent template + tool guidance (`src/backends/shared/nativeToolPrompts.ts`); `cascade-tools` shell-safety rules belong there, not in per-engine code. LLMist receives the agent system prompt directly. diff --git a/docs/areas/pm-integrations.md b/docs/areas/pm-integrations.md new file mode 100644 index 00000000..d197a6d1 --- /dev/null +++ b/docs/areas/pm-integrations.md @@ -0,0 +1,34 @@ +# PM integrations + +**Applies to:** `src/integrations/**`, `src/pm/**`, `src/jira/**`, `src/linear/**`, `src/trello/**`, `src/workflow/**`, `src/api/routers/pm-discovery.ts`, `src/api/routers/webhooks/**`, `web/src/components/projects/pm-providers/**`, `web/src/components/projects/pm-wizard*` + +Read [`src/integrations/README.md`](../../src/integrations/README.md) before adding or changing a provider — it is the contract (manifest fields, conformance harness, step-by-step guide). Nothing below restates it; each bullet names the rule and the README section that specifies it. + +## Adding or changing a provider + +- One manifest (`src/integrations/pm//manifest.ts`) plus one import in each barrel: `src/integrations/pm/index.ts` and `web/src/components/projects/pm-providers/index.ts`. Zero edits to `pm-wizard.tsx`, `pm-wizard-hooks.ts`, `pm-wizard-common-steps.tsx` — `tests/unit/integrations/new-provider-surface.test.ts` pins their hashes. The one shared dashboard file that still takes an edit is `pm-wizard-state.ts` (the provider's state slice + actions) → README § Adding a new PM provider. +- The provider owns its Zod config schema in `config-schema.ts`; `src/config/schema.ts` imports it. Declare `configSchema` + `configFixture` on the manifest so the conformance harness catches round-trip drift. +- Adapter call sites take branded `StateId` / `LabelId` / `ContainerId` from `src/pm/ids.ts` — passing a state *name* where an ID is expected must stay a compile error. +- Build auth headers only through `src/integrations/pm/_shared/auth-headers.ts`; the pre-commit hook runs `tests/unit/integrations/auth-header-provenance.test.ts` against hand-assembled `Bearer` strings. +- A discovery capability that backs a wizard picker must return the complete list (paginate) — the picker filters client-side → README § PMProviderManifest contract. +- Run `npx vitest run --project unit-core tests/unit/integrations/pm-conformance.test.ts` — failures name the violated contract. + +## Router adapters (`src/router/adapters/*.ts`) + +- Wrap `triggerRegistry.dispatch(ctx)` in `withPMScopeForDispatch(fullProject, dispatch)` (`src/router/adapters/_shared.ts`) **in addition to** the per-PM credential scope; mirror `github.ts:dispatchWithCredentials`. Without it the pipeline-capacity gate fails closed → [10-resilience § Max in-flight items](../architecture/10-resilience.md). CI: `tests/unit/integrations/pm-router-adapter-pm-scope.test.ts`. +- `extractProjectIdFromJob` must return `null` for other providers' jobs → README § PMProviderManifest contract. +- Ack posting goes through `dispatchPMAck` (`src/router/pm-ack-dispatch.ts`); never branch on `pmType` literals → README § PM-ack dispatch coverage invariant. + +## JIRA + +- Route every REST v3 call through `resolveJiraApiBaseUrl(creds)` (`src/jira/api-host.ts`); `authType` selects the host and both modes are HTTP Basic → README § JIRA authentication modes. +- Match statuses by locale-invariant **ID** first, name as fallback (`resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions`, `transitions[].to.id`) → README § JIRA status matching is ID-based. +- Two projects on one JIRA key need a routing discriminator; matching is exact and case-sensitive → README § Shared-key routing contract. +- `createWorkItem` reads `issueTypes.task`, never `issueTypes.default` → README § JIRA issue-type mapping. + +## Cross-provider contracts + +- Custom workflow statuses: `resolveLifecycleConfig` must spread the full `lists` / `statuses` record; status-changed triggers resolve through `resolvePMStatusAgent*FromWorkflowDefinitions` → README § Custom workflow status. +- Inline checklists (Linear, JIRA) rewrite the whole description — keep every mutation inside `withDescriptionMutationLock` and implement `createChecklistWithItems` → README § Checklist implementation by provider. +- Never write MIME-detection or image-download logic in an adapter; `extractMarkdownImages` + `downloadAndPrepareImages` are the shared path → README § Image delivery contract. +- Friction and alert work items are plain `createWorkItem` + optional `moveWorkItem` into the `friction` / `alerts` slot → README § Friction report materialization, § Alerting work-item materializer. diff --git a/docs/areas/router-dispatch.md b/docs/areas/router-dispatch.md new file mode 100644 index 00000000..353c06d0 --- /dev/null +++ b/docs/areas/router-dispatch.md @@ -0,0 +1,26 @@ +# Router, triggers and dispatch + +**Applies to:** `src/router/**`, `src/triggers/**`, `src/webhook/**`, `src/queue/**` + +Mechanism lives in [02-webhook-pipeline](../architecture/02-webhook-pipeline.md), [03-trigger-system](../architecture/03-trigger-system.md), [10-resilience](../architecture/10-resilience.md) and [`src/triggers/README.md`](../../src/triggers/README.md). Below are the rules that are easy to break from inside this area. + +## Locks and dispatch + +- The work-item lock is per `(projectId, workItemId, agentType)` with a 30-minute TTL; different agent types may run concurrently on one work item → 10-resilience § Concurrency Controls. +- Any path that marks a lock must register its compensation: post-enqueue failures release through `worker.on('failed')` → `releaseLocksForFailedJob`; enqueue failures must not mark a lock at all. `wedged_lock_canary` in Sentry means a path broke this → 10-resilience § Dispatch retries, § Wedged-lock canary. +- Classify new dispatch errors in `src/router/dispatch-error-classifier.ts`: transient errors propagate so BullMQ retries; terminal ones are wrapped in `UnrecoverableError`. A capacity miss waits for a slot — it never throws → 10-resilience § Dispatch retries. +- PM router adapters wrap `triggerRegistry.dispatch` in `withPMScopeForDispatch` → [pm-integrations](./pm-integrations.md). + +## Triggers + +- Return `deferredRecheck` only from GitHub handlers — bare re-dispatch is GitHub-only; PM and Sentry adapters embed the pre-resolved `triggerResult` and would replay it → 10-resilience § Deferred re-check exhaustion. +- Review dispatch after a successful implementation and the `check-suite-success` trigger share the `claimReviewDispatch` dedup key — use it on any new review-dispatch path → 03-trigger-system § Shared Agent Execution. +- The implementation freshness gate runs only for `agentType === 'implementation'`; do not extend it to follow-up agents → 03-trigger-system § Shared Agent Execution. +- Every SCM handler filters self-events with `isCascadeBot(login)`; the self-directed `review_requested` exemption is the only exception → 10-resilience § Loop Prevention. +- Use the canonical `TRIGGER_EVENTS` constants and the shared result builders; new PM events go through `processPMWebhook()` → `src/triggers/README.md`. +- PM status-change webhooks coalesce for `PM_COALESCE_WINDOW_MS`; the ack comment is deferred to job fire time → 02-webhook-pipeline. + +## Worker lifecycle + +- Keep `formatCrashReason` output (`src/router/active-workers.ts`) grep-stable — `tests/unit/router/container-manager-diagnostics.test.ts` pins it; `OOMKilled=true` is the only memory signal → 10-resilience § Orphan Cleanup. +- PR checkout is by `refs/pull//head`, never by branch name; a non-zero git exit is fatal → [01-services § Repository checkout](../architecture/01-services.md). diff --git a/src/agents/prompts/templates/partials/documentation-maintenance.eta b/src/agents/prompts/templates/partials/documentation-maintenance.eta index a74b0eb1..ee55a460 100644 --- a/src/agents/prompts/templates/partials/documentation-maintenance.eta +++ b/src/agents/prompts/templates/partials/documentation-maintenance.eta @@ -15,7 +15,7 @@ bug fixes that restore behavior already described in docs. #### What to Check -- **CLAUDE.md / AGENTS.md** — Developer conventions, setup steps, commands +- **CLAUDE.md / AGENTS.md** — universal conventions, commands and invariants only. CASCADE injects these files when an agent's context pipeline includes `contextFiles`, so keep them short: area-specific detail belongs in the repo's docs, and ticket IDs, dates or incident narrative belong in a changelog - **README.md** — User-facing overview, installation, usage - **docs/** — Architecture guides, feature documentation - **JSDoc / inline comments** — Function signatures, complex logic explanations @@ -36,7 +36,6 @@ RipGrep(pattern="@param|@returns", glob="src/path/to/file.ts") Before marking your work complete, verify: - [ ] Public API changes are reflected in JSDoc (params, return types, thrown errors) -- [ ] New env vars or config options are documented in CLAUDE.md or README +- [ ] New env vars or config options are documented where the repo catalogues them (`.env.example`, docs/, or README) — add to CLAUDE.md only when it is a universal setup step - [ ] New architectural patterns are noted in the relevant docs/ file - [ ] Removed/renamed features no longer appear in docs as current behavior - diff --git a/src/integrations/README.md b/src/integrations/README.md index e1231c3e..947c84f8 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -4,13 +4,7 @@ CASCADE's PM providers (Trello, JIRA, Linear, and any future Asana/GitLab/ClickUp) are built on a **provider manifest** pattern. One file describes the provider end-to-end; one registry iterates manifests; a behavioral conformance harness guarantees each manifest satisfies its declared contracts. -This document is the canonical guide for adding a new PM provider. Five specs shape it: - -- **Spec [006](../../docs/specs/006-pm-integration-plug-and-play.md.done)** — introduced the manifest pattern + wiring-level conformance (2026-04-15/16). -- **Spec [009](../../docs/specs/009-pm-integration-hardening.md.done)** — hardened the contracts: branded ID types, manifest-owned config schemas (eliminating the #1138/#1142 drift class), unified `pm.discover` endpoint, behavioral conformance harness with in-memory lifecycle scenario, single registration entrypoint, and auth-header provenance enforcement. -- **Spec [010](../../docs/specs/010-pm-integration-hardening-followups.md.done)** — follow-up cleanup: generic `pm.discovery.createLabel` / `createCustomField` mutation endpoints + manifest hooks, `currentUser` discovery capability, real shared React components for every `StandardStepKind`. -- **Spec [011](../../docs/specs/011-pm-wizard-shared-migration.md.done)** — migrated all three production providers (Trello, JIRA, Linear) onto the shared step components; added a 7th `StandardStepKind: custom-field-mapping`; widened `container-pick` / `project-scope` / `webhook-url-display` with optional props; deleted the three legacy `pm-wizard-{trello,jira,linear}-steps.tsx` files. -- **Spec [012](../../docs/specs/012-pm-webhook-manifest-migration.md.done)** — migrated each provider's webhook UX (programmatic create for Trello/JIRA, signing-secret + manual-setup for Linear) into its own manifest webhook step adapter. Deleted the legacy `WebhookStep` + `LinearWebhookInfoPanel` + supporting hooks. Every PM wizard step now renders via the manifest path without exception. +This document is the canonical guide for adding a new PM provider. The history of how the contract evolved lives in [`CHANGELOG.md`](../../CHANGELOG.md). --- @@ -62,9 +56,9 @@ See [`src/integrations/pm/manifest.ts`](./pm/manifest.ts) for the authoritative | `isSelfAuthoredHook?` | Optional — returns `true` when the event was authored by CASCADE itself (for loop prevention). | | `createLabel?` | Optional — enables the wizard's "Create label" button. Called via the generic `pm.discovery.createLabel` tRPC endpoint; signature is `({credentials, containerId, name, color?}) => {id, name, color}`. | | `createCustomField?` | Optional — enables wizard-driven custom-field creation. Called via `pm.discovery.createCustomField`; signature is `({credentials, containerId, name}) => {id, name, type}`. JIRA fields are global (the hook ignores containerId). | -| `configToCredentials?` | Optional — promotes non-secret connection fields from `project_integrations.config` into the credentials bag `createDiscoveryProvider` consumes. Signature: `(config: unknown) => Record`. Invoked only on the `projectId` path of `pm.discovery.*`; `project_credentials` values win on key collisions. Declare this when your provider stores tenant/host info in config instead of credentials (JIRA's `baseUrl` → `base_url`). Without it, edit-mode wizard re-verification constructs a client with empty host info — see prod incident 2026-04-24. | +| `configToCredentials?` | Optional — promotes non-secret connection fields from `project_integrations.config` into the credentials bag `createDiscoveryProvider` consumes. Signature: `(config: unknown) => Record`. Invoked only on the `projectId` path of `pm.discovery.*`; `project_credentials` values win on key collisions. Declare this when your provider stores tenant/host info in config instead of credentials (JIRA's `baseUrl` → `base_url`). Without it, edit-mode wizard re-verification constructs a client with empty host info. | -### Plan 009 hardened-contract fields (all optional; providers opt in) +### Hardened-contract fields (all optional; providers opt in) | Field | What it does | |---|---| @@ -72,7 +66,7 @@ See [`src/integrations/pm/manifest.ts`](./pm/manifest.ts) for the authoritative | `configFixture?` | Sample config used by the harness's round-trip asserter. Must parse against `configSchema`. | | `discoveryCapabilities?` | `{ teams?, boards?, labels?, states?, projects?, containers?, customFields?, currentUser? }`. Each flag means "`adapter.discover(capability, args)` returns a list of that shape" (or a single `{id, name, displayName?}` object for `currentUser`). The generic `pm.discover` tRPC endpoint dispatches through this registry. | | `createDiscoveryProvider?` | `(opts) => PMProvider`. Factory producing a discovery-scoped adapter outside a project context (wizard setup, before the config is saved). Receives raw credentials from the wizard. | -| `wizardSpec?` | `{ steps: Array }`. Declarative step list the shared wizard generator renders. Standard kinds: `credentials`, `container-pick`, `status-mapping`, `label-mapping`, `webhook-url-display`, `project-scope`. | +| `wizardSpec?` | `{ steps: Array }`. Declarative step list the shared wizard generator renders. Standard kinds: `credentials`, `container-pick`, `status-mapping`, `label-mapping`, `webhook-url-display`, `project-scope`, `custom-field-mapping`. | | `lifecycle?` | `{ enabled: true, fixtureKey: string }`. Opts into the behavioral conformance harness's full lifecycle scenario. `fixtureKey` is looked up in the test-local `LIFECYCLE_FIXTURES` registry — the manifest doesn't import from `tests/helpers/`. | > **Discovery must return the _complete_ provider list.** A discovery capability that backs a wizard picker (e.g. `container-pick` for `projects` / `boards` / `teams`) must return **every** item from the provider, not just the first page. The dashboard picker filters **client-side** (the shared `Combobox` does the search locally), so a provider adapter that returns a truncated first page silently hides everything past it — the operator can neither see nor search for the missing entries. Provider adapters must therefore **paginate the underlying API** until it reports the last page. The reference case is JIRA's `jiraClient.searchProjects()` (`src/jira/client.ts`): JIRA's `/rest/api/3/project/search` endpoint is paginated, so the method loops on `isLast` / `startAt` (with a `MAX_PROJECT_PAGES` safety cap) to collect all projects before returning. (A server-side `query` param + async debounced picker is the scalable follow-up for orgs with thousands of items, but full pagination is the correct baseline.) @@ -334,24 +328,10 @@ The PM barrel (`src/integrations/pm/index.ts`): 1. Imports each provider's `index.js` (side effect: `registerPMProvider(manifest)`). 2. Iterates `listPMProviders()` and mirrors each manifest's `pmIntegration` into the cross-category `integrationRegistry` — so `integration-validation.ts` and the capability resolver see PM providers alongside SCM + alerting. -SCM (GitHub) and alerting (Sentry) integrations remain on the legacy `IntegrationModule` pattern — the manifest pattern is PM-only (spec 006 scope). Both self-register via their own `register.ts` side-effect modules, transitively pulled in by the entrypoint. +SCM (GitHub) and alerting (Sentry) integrations remain on the legacy `IntegrationModule` pattern — the manifest pattern is PM-only. Both self-register via their own `register.ts` side-effect modules, transitively pulled in by the entrypoint. `pmRegistry` (`src/pm/registry.ts`) still exists as a **read-only delegate** over `pmProviderRegistry` — the ~9 unmigrated call sites (webhook handlers, manual runner, credential scope, lifecycle, GitHub adapter) keep working without changes. Prefer `getPMProvider(id)` / `listPMProviders()` from `src/integrations/pm/registry.ts` in new code. -### Behavioral contract fields (spec 009/1) - -The manifest accepts four optional fields beyond the wiring contracts — each opts the provider into a behavioral assertion group in the conformance harness: - -| Field | Purpose | Harness assertion | -|---|---|---| -| `configSchema: z.ZodType` | Declarative Zod schema for the persisted integration config | Round-trip identity: parse → serialize → re-parse → deep-equal | -| `discoveryCapabilities: { teams?, boards?, labels?, states?, projects?, customFields?, containers? }` | Which discovery queries the adapter can serve | Each declared capability returns an array from `adapter.discover(k, args)` | -| `wizardSpec: { steps: [...] }` | Declarative list of standard wizard steps | Rendered by the generator at `web/src/components/projects/pm-providers/generator.tsx` | -| `lifecycle: { enabled: true, fixture? }` | Opt into the full lifecycle scenario | Harness runs `runLifecycleScenario` (create → list → move → checklist → comment → delete) | -| `createDiscoveryProvider: (opts?) => PMProvider` | Factory producing a discovery-scoped adapter outside a project context | Powers the generic `pm.discover` tRPC endpoint | - -All fields are optional; legacy manifests that don't declare them skip the corresponding harness groups. Plans 2/3/4 flip each real provider on individually. - --- ## Conformance harness — what CI enforces @@ -369,60 +349,23 @@ All fields are optional; legacy manifests that don't declare them skip the corre - `triggerHandlers` have unique names - `platformClientFactory(projectId)` returns an object with `postComment` + `deleteComment` - `pmIntegration.type` is wired -- `dispatchPMAck` (the consolidated PM-ack helper at `src/router/pm-ack-dispatch.ts`) reaches this provider without throwing — pinned by the per-provider assertion added in spec 017 plan 1 +- `dispatchPMAck` (the consolidated PM-ack helper at `src/router/pm-ack-dispatch.ts`) reaches this provider without throwing + +Declaring the optional hardened-contract fields opts a manifest into further groups: `configSchema` + `configFixture` → round-trip identity (parse → serialize → re-parse → deep-equal); `discoveryCapabilities` → each declared capability returns a list from `adapter.discover(k, args)`; `lifecycle` → the full `runLifecycleScenario` (create → list → move → checklist → comment → delete) against the fixture registered under `fixtureKey`; `createDiscoveryProvider` → powers the generic `pm.discover` endpoint. Manifests that omit a field skip that group. A `TestProvider` fixture in `tests/helpers/testPMProvider.ts` is the minimal reference implementation — copy its shape when starting a new provider. The harness runs against TestProvider + Trello + JIRA + Linear. -### PM-ack dispatch coverage invariant (spec 017 plan 1) +### PM-ack dispatch coverage invariant Router-side PM acknowledgment posting (the comment that says "🔧 On it" on the PM card when a PM-focused agent like `backlog-manager` starts work, triggered from a GitHub webhook) goes through **one** code path: `dispatchPMAck` in `src/router/pm-ack-dispatch.ts`. That helper looks up the provider in the manifest registry and invokes `manifest.platformClientFactory(projectId).postComment(workItemId, message)` directly — **no `pmType` literal branching anywhere on the dispatch surface**. -The consolidation closed a parallel-path drift incident verified live on 2026-04-29 (`ucho`): the router-adapter's local helper had Trello + JIRA branches but no Linear branch, so PM-focused agents triggered against Linear-based projects silently skipped their ack with `WARN: Unknown PM type for PM-focused agent ack, skipping` (24× per day in prod). A sibling helper at `src/triggers/shared/pm-ack.ts` had all three branches; both now delegate to `dispatchPMAck`. +It replaced two parallel helpers that had drifted apart — one had Trello + JIRA branches but no Linear branch, so Linear projects silently skipped the ack. Both legacy call sites now delegate to `dispatchPMAck`. A new PM provider lands the dispatch path **for free** the moment its manifest is registered — no edits to `pm-ack-dispatch.ts` or to either of the call sites. Failure modes: - Provider's `platformClientFactory` returns a client whose `postComment` throws → conformance harness's `dispatchPMAck reaches this provider without throwing` assertion fails in CI with a precise per-provider message. - A future maintainer adds `if (pmType === 'asana')` branching to either call site → the static guard at `tests/unit/router/pm-ack-dispatch.test.ts` (PM-ack dispatch surface: no literal pm-type branching) fails loudly with a file:line citation. - Project pinned to a `pm.type` that's no longer in the registry (configuration error) → `dispatchPMAck` logs at ERROR + captures Sentry under tag `pm_ack_unknown_pm_type` (no longer a silent WARN). -### Provider migration status (plan 009 — PM integration hardening) - -| Provider | configSchema | discoveryCapabilities | wizardSpec | lifecycle | Branded IDs on adapter | -|---|---|---|---|---|---| -| **Trello** (plan 009/2) | ✅ `trelloConfigSchema` | ✅ boards, labels, customFields | ✅ 5 standard steps | ✅ `lifecycle.fixtureKey: 'trello'` | ✅ move/addLabel/removeLabel/listWorkItems | -| **JIRA** (plan 009/3) | ✅ `jiraConfigSchema` | ✅ projects, states, labels (empty — JIRA is free-form), customFields | ✅ 5 standard steps | ✅ `lifecycle.fixtureKey: 'jira'` | ✅ move/addLabel/removeLabel/listWorkItems | -| **Linear** (plan 009/4) | ✅ `linearConfigSchema` (locks #1138/#1142) | ✅ teams, states, labels, projects | ✅ 6 standard steps (includes project-scope from spec 005) | ✅ `lifecycle.fixtureKey: 'linear'` | ✅ move/addLabel/removeLabel/listWorkItems (locks #1117/#1137/#1139) | -| **Fake** (plan 009/1, test fixture) | ✅ | ✅ all | ✅ | ✅ | N/A (the fake parses branded IDs internally) | - -All three real providers are now on the hardened contracts. Plan 009/4 also ships `tests/unit/pm/linear/regression-2026-04.test.ts` — 12 tests, one set per 2026-04 bug class, that fail loudly if any of the six classes regresses. See `linearManifest` at `src/integrations/pm/linear/manifest.ts` for the reference migration (Linear's surface area is the richest). - -### Post-spec-010 additions (2026-04-18) - -| Area | Change | -|---|---| -| Mutations | Generic `pm.discovery.createLabel` / `pm.discovery.createCustomField` tRPC endpoints dispatch through the manifest's optional `createLabel` / `createCustomField` hooks. Five previous caller sites (Trello/JIRA label + custom-field wizards + Linear label wizard) now consume the generic endpoints. | -| Discovery | `currentUser` capability added to `DiscoveryCapability`. All three real providers declare it (Trello via `/members/me`, JIRA via `/rest/api/3/myself`, Linear via `viewer`). The wizard's verify-button flow reads it through the unified `pm.discovery.discover` endpoint instead of per-provider procedures. | -| Wizard UI | Six real shared step components live at `web/src/components/projects/pm-providers/steps/*.tsx`, one per `StandardStepKind`. A new provider with purely-standard steps renders its wizard through `renderStandardStep` + `STANDARD_STEP_COMPONENTS` with zero per-provider step code. | -| Shared surface guard | `tests/unit/integrations/new-provider-surface.test.ts` now also pins the six step-component files — new providers should consume them, not fork them. | - -### Post-spec-011 additions (2026-04-18) - -| Area | Change | -|---|---| -| Wizard migration | All three production providers (Trello, JIRA, Linear) now render every standard wizard step through the shared components. The three legacy `pm-wizard-{trello,jira,linear}-steps.tsx` files are **deleted**. Zero per-provider step UI outside of explicit `kind: 'custom'` steps (Trello OAuth, JIRA issue-type). | -| Parent wizard | `pm-wizard.tsx` now iterates over `manifestDef.steps` dynamically — the old spec-006-era "3 hardcoded stepIndex slots" layout is gone. Each manifest step gets its own WizardStep slot. The legacy `WebhookStep` was retained temporarily for programmatic webhook registration (Trello/JIRA) and signing-secret UX (Linear); it was fully migrated into the manifest path in spec 012 (see Post-spec-012 additions below). | -| 7th StandardStepKind | `custom-field-mapping` shared component (with optional `onCreateCustomField` + `fieldDefaults` props) wires `manifest.createCustomField`. Trello and JIRA use it; Linear doesn't have a custom-field concept. | -| Shared-component widenings (additive) | `container-pick` and `project-scope` support optional `searchable: boolean` (renders via cmdk `Combobox`). `webhook-url-display` supports optional inline signing-secret input (`secretFieldRole` / `secretValue` / `onSecretChange`). `label-mapping` supports optional `labelDefaults?` to pre-populate the Create input + thread color. `custom-field-mapping` supports optional `fieldDefaults?`. | -| Shared surface guard | Step-component file pin extended to seven entries. | - -### Post-spec-012 additions (2026-04-18+) - -| Area | Change | -|---|---| -| Webhook-UX migration complete | Every PM wizard step, without exception, renders via the manifest path. Trello, JIRA, and Linear each own their webhook step via a per-provider adapter (`pm-providers//webhook-step.tsx`) — Fragment composition around the shared `WebhookUrlDisplayStep`. Trello + JIRA compose with programmatic "Create Webhook" button + active-webhooks list + delete + curl fallback (via existing `webhooks.create/list/delete({trelloOnly|jiraOnly:true})` tRPC endpoints). Linear composes with info banner + `ProjectSecretField` (`LINEAR_WEBHOOK_SECRET`) + 5-step manual setup instructions. | -| Legacy deletions | `WebhookStep` + `LinearWebhookInfoPanel` + `useWebhookManagement` + `useLinearWebhookInfo` all deleted. `pm-wizard-common-steps.tsx` now only exports `SaveStep`. Legacy test file `pm-wizard-webhooks-step.test.ts` deleted — assertions moved into per-provider adapter tests. | -| Parent-wizard filter | The `-webhook` id-skip filter (stopgap from plan 011/4) is gone. `renderedManifestSteps = manifestDef.steps.map(...)` — no filter. | -| New-provider guarantee | Adding a PM provider requires zero edits to `pm-wizard.tsx`, `pm-wizard-common-steps.tsx`, or `pm-wizard-hooks.ts`. New providers add one import to the frontend barrel (`web/src/components/projects/pm-providers/index.ts`) — the symmetric counterpart of the backend barrel — and `pm-wizard.tsx` picks it up automatically. The provider picker, edit hydration dispatch (`ProviderWizardDefinition.buildEditState`), config serialization (`ProviderWizardDefinition.buildIntegrationConfig`), verification-button readiness (`areCredentialsReadyFromMetadata`), mutation auth path (`buildProviderAuthArgFromMetadata`), and save credential persistence are all metadata/provider-definition driven; no shared edits required beyond the barrel import. **Shared dashboard state** (`pm-wizard-state.ts`) must still compose the new provider's state slice and action type — see step 4 of "Adding a new PM provider" below. | - --- ## Custom workflow status — provider parity contract @@ -516,7 +459,7 @@ Spec 009 AC #10: **a new PM provider PR should not need to edit shared router / 2. **Wire the backend manifest** via a single import in `src/integrations/pm/index.ts` (`import './/index.js';`). No other backend file needs to change — the `single-entrypoint` test guards this. -3. **Frontend folder** at `web/src/components/projects/pm-providers//`: `wizard.ts` (`ProviderWizardDefinition` with `auth`, `credentialPersistence`, `formatVerificationDisplay`, `buildIntegrationConfig`, `buildEditState`, and `useProviderHooks` if the provider needs discovery / label creation / custom-field creation / webhook registration), `state.ts` for the provider-owned wizard state slice/actions/reducer/defaults, `hooks.ts` for provider-owned discovery/mutation/auth/webhook wrappers, `auth.ts` for reusable auth metadata when useful, and `index.ts` for side-effect registration (`registerProviderWizard(ProviderWizard)`). For shared wizard steps declared on `manifest.wizardSpec`, the generator in `pm-providers/generator.tsx` dispatches directly to the real shared step components at `pm-providers/steps/*.tsx` — there are **seven** kinds: `credentials`, `container-pick`, `status-mapping`, `label-mapping`, `webhook-url-display`, `project-scope`, `custom-field-mapping`. A provider with purely standard steps writes **zero** per-provider step components; Trello, JIRA, and Linear all use the shared components for every standard kind. Provide `providerHooks` (returned from `useProviderHooks`) to forward discovery data + mutation callbacks into the shared components; the generator spreads `ctx.providerHooks` as props. Unknown step `kind` values still warn-and-render a placeholder. **Provider-specific UI** ships either as (a) `kind: 'custom'` steps declared on the manifest and resolved to provider-folder components (Trello OAuth popup, JIRA issue-type mapping), or (b) Fragment compositions around a shared step when the base UX is standard but needs augmentation (Trello/JIRA webhook steps compose `WebhookUrlDisplayStep` + programmatic Create UX + active-webhook normalization; Linear composes `WebhookUrlDisplayStep` + `ProjectSecretField` + setup instructions — see `pm-providers/{trello,jira,linear}/webhook-step.tsx` for the reference composition pattern). Shared `pm-wizard-hooks.ts` remains limited to metadata-driven verification/save shells and provider-agnostic mutation factories. +3. **Frontend folder** at `web/src/components/projects/pm-providers//`: `wizard.ts` (`ProviderWizardDefinition` with `auth`, `credentialPersistence`, `formatVerificationDisplay`, `buildIntegrationConfig`, `buildEditState`, and `useProviderHooks` if the provider needs discovery / label creation / custom-field creation / webhook registration), `state.ts` for the provider-owned wizard state slice/actions/reducer/defaults, `hooks.ts` for provider-owned discovery/mutation/auth/webhook wrappers, `auth.ts` for reusable auth metadata when useful, and `index.ts` for side-effect registration (`registerProviderWizard(ProviderWizard)`). For shared wizard steps declared on `manifest.wizardSpec`, the generator in `pm-providers/generator.tsx` dispatches directly to the real shared step components at `pm-providers/steps/*.tsx` — there are **seven** kinds: `credentials`, `container-pick`, `status-mapping`, `label-mapping`, `webhook-url-display`, `project-scope`, `custom-field-mapping`. A provider with purely standard steps writes **zero** per-provider step components; Trello, JIRA, and Linear all use the shared components for every standard kind. Provide `providerHooks` (returned from `useProviderHooks`) to forward discovery data + mutation callbacks into the shared components; the generator spreads `ctx.providerHooks` as props. Unknown step `kind` values still warn-and-render a placeholder. The shared steps accept optional widenings: `container-pick` / `project-scope` take `searchable` (cmdk `Combobox`), `webhook-url-display` takes an inline signing-secret input (`secretFieldRole` / `secretValue` / `onSecretChange`), `label-mapping` takes `labelDefaults`, and `custom-field-mapping` takes `fieldDefaults`; the generator dispatches through `renderStandardStep` + `STANDARD_STEP_COMPONENTS`. **Provider-specific UI** ships either as (a) `kind: 'custom'` steps declared on the manifest and resolved to provider-folder components (Trello OAuth popup, JIRA issue-type mapping), or (b) Fragment compositions around a shared step when the base UX is standard but needs augmentation (Trello/JIRA webhook steps compose `WebhookUrlDisplayStep` + programmatic Create UX + active-webhook normalization; Linear composes `WebhookUrlDisplayStep` + `ProjectSecretField` + setup instructions — see `pm-providers/{trello,jira,linear}/webhook-step.tsx` for the reference composition pattern). Shared `pm-wizard-hooks.ts` remains limited to metadata-driven verification/save shells and provider-agnostic mutation factories. 4. **Update shared dashboard state** in `web/src/components/projects/pm-wizard-state.ts`. This is the one shared dashboard file a new provider must edit while `WizardState` remains an aggregate type: - Import the provider's state-slice helpers from `pm-providers//state.ts`. @@ -533,7 +476,7 @@ Spec 009 AC #10: **a new PM provider PR should not need to edit shared router / 7. **Run the conformance harness**: `npx vitest run --project unit-core tests/unit/integrations/pm-conformance.test.ts`. Behavioral contracts run against your provider automatically once `configSchema` / `discoveryCapabilities` / `lifecycle` are declared. Failures name the contract. -8. **Provider-specific unit tests** in `tests/unit/pm//` — adapter tests (vi.mock the client), config-schema round-trip, discovery shape, wizardSpec, adapter branded IDs. +8. **Provider-specific unit tests** in `tests/unit/pm//` — adapter tests (vi.mock the client), config-schema round-trip, discovery shape, wizardSpec, adapter branded IDs. Linear's `tests/unit/pm/linear/regression-2026-04.test.ts` (one test set per past bug class) is the model for pinning fixed bugs. The shared orchestration files (`pm-wizard.tsx`, `pm-wizard-hooks.ts`, `pm-wizard-common-steps.tsx`) require zero edits beyond the barrel import in step 5. The `new-provider-surface` snapshot test proves your PR does not modify shared router / worker / CLI / dashboard orchestration or central schema files. The one deliberate shared-dashboard exception is `pm-wizard-state.ts` for provider-specific state fields and reducer actions (step 4 above). diff --git a/tests/README.md b/tests/README.md index 9a48c5b4..28b62976 100644 --- a/tests/README.md +++ b/tests/README.md @@ -66,7 +66,9 @@ npx vitest run tests/unit/triggers/trello/status-changed.test.ts TEST_DATABASE_URL=... npx vitest run --project integration tests/integration/.test.ts ``` -Documentation drift guards live in `tests/unit/architecture-docs.test.ts` and run under `unit-core`. They check architecture deep-dive structure, active Markdown relative links (including links to archived `.md.done` specs), canonical trigger names such as `alerting:issue-alert`, current `cascade-tools` namespaces, and `CLAUDE.md`/`AGENTS.md` synchronization. +The integration project discovers its database in this order (`tests/integration/helpers/db.ts`): `TEST_DATABASE_URL` in the environment → `TEST_DATABASE_URL` in `.cascade/env` → the Docker Compose Postgres at `127.0.0.1:5433` (`npm run test:db:up`) → the `cascade-postgres-test` container IP. The database is created if missing. **If none is reachable, integration tests silently skip** — check the run summary, not just the exit code. The full suite takes ~4 min; target one file while iterating. + +Documentation drift guards live in `tests/unit/architecture-docs.test.ts` and run under `unit-core`. They check architecture deep-dive structure, active Markdown relative links (including links to archived `.md.done` specs), canonical trigger names such as `alerting:issue-alert`, current `cascade-tools` namespaces, `CLAUDE.md`/`AGENTS.md` synchronization, and the instruction-file budget: `CLAUDE.md` must stay under 200 lines and under half of `CONTEXT_OFFLOAD_CONFIG.inlineThreshold` because CASCADE's `contextFiles` step injects it inline, must not use `@` imports, and — together with `docs/areas/*.md` — must not carry ticket IDs, spec numbers or dates; every area doc must be ≤ 60 lines, open with an `**Applies to:**` scope line, and be linked from `CLAUDE.md`. --- diff --git a/tests/unit/architecture-docs.test.ts b/tests/unit/architecture-docs.test.ts index 0d882b9a..02858dd9 100644 --- a/tests/unit/architecture-docs.test.ts +++ b/tests/unit/architecture-docs.test.ts @@ -1,11 +1,14 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; +import { CONTEXT_OFFLOAD_CONFIG } from '../../src/config/claudeCodeConfig.js'; +import { estimateTokens } from '../../src/config/reviewConfig.js'; import { TRIGGER_EVENTS } from '../../src/triggers/shared/events.js'; const REPO_ROOT = path.resolve(__dirname, '../..'); const DOCS_ROOT = path.resolve(__dirname, '../../docs'); const ARCH_DIR = path.join(DOCS_ROOT, 'architecture'); -const ROOT_DOCS = ['README.md', 'CLAUDE.md', 'AGENTS.md']; +const AREAS_DIR = path.join(DOCS_ROOT, 'areas'); +const ROOT_DOCS = ['README.md', 'CLAUDE.md', 'AGENTS.md', 'SECURITY.md', 'CONTRIBUTING.md']; const EXTRA_ACTIVE_DOCS = [ 'src/integrations/README.md', 'src/gadgets/README.md', @@ -13,10 +16,38 @@ const EXTRA_ACTIVE_DOCS = [ 'tests/README.md', ]; +/** + * Instruction-file budgets. + * + * CLAUDE.md is loaded into every interactive Claude Code session and `cat`-injected + * whenever a CASCADE context pipeline runs the `contextFiles` step + * (`src/agents/utils/setup.ts:readContextFiles`). + * Claude Code guidance: adherence drops past ~200 lines. The worker path offloads + * the file out of the prompt once it exceeds `CONTEXT_OFFLOAD_CONFIG.inlineThreshold` + * (`src/backends/shared/contextFiles.ts`); we keep half of that as headroom. + */ +const CLAUDE_MD_MAX_LINES = 200; +const CLAUDE_MD_INLINE_BUDGET_SHARE = 0.5; +/** Area docs are pointer layers: imperatives + links, never restatements. */ +const AREA_DOC_MAX_LINES = 60; +/** Ticket IDs, spec/plan numbers and dates are history — they belong in CHANGELOG.md. */ +const NARRATIVE_PATTERNS = [ + /\bMNG-\d+\b/, + /\bspec[ -]?\d{3}\b/i, + /\bplan \d{3}\b/i, + /\b20\d{2}-\d{2}-\d{2}\b/, +]; +/** `@path` outside code spans is a Claude Code import; workers never expand it. */ +const CLAUDE_MD_IMPORT_PATTERN = /(^|\s)@(?:[~.]{0,2}\/\S+|[\w.-]+(?:\/\S+|\.md\b))/m; + function readDoc(filePath: string): string { return readFileSync(filePath, 'utf-8'); } +function stripCode(content: string): string { + return content.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, ''); +} + function extractMarkdownLinks(content: string): string[] { const linkPattern = /\[[^\]]+\]\((\.\.?\/[^)\s]+\.md(?:\.done)?(?:#[^)]+)?)\)/g; return Array.from(content.matchAll(linkPattern), (m) => m[1]); @@ -257,8 +288,6 @@ describe('Architecture documentation', () => { path.join(ARCH_DIR, '10-resilience.md'), path.join(REPO_ROOT, 'src/integrations/README.md'), path.join(REPO_ROOT, 'src/gadgets/README.md'), - path.join(REPO_ROOT, 'CLAUDE.md'), - path.join(REPO_ROOT, 'AGENTS.md'), path.join(REPO_ROOT, 'CHANGELOG.md'), ]; const combined = docs.map(readDoc).join('\n'); @@ -268,4 +297,71 @@ describe('Architecture documentation', () => { } }); }); + + describe('instruction files (CLAUDE.md + docs/areas)', () => { + const claudePath = path.join(REPO_ROOT, 'CLAUDE.md'); + const claude = readDoc(claudePath); + const areaDocs = existsSync(AREAS_DIR) ? listMarkdownDocs(AREAS_DIR) : []; + + it('CLAUDE.md stays inline for CASCADE workers and under the Claude Code line guidance', () => { + const lines = claude.split('\n').length; + expect( + lines, + `CLAUDE.md is ${lines} lines; keep it ≤ ${CLAUDE_MD_MAX_LINES} — move path-scoped content to docs/areas/`, + ).toBeLessThanOrEqual(CLAUDE_MD_MAX_LINES); + + const tokens = estimateTokens(claude); + const budget = CONTEXT_OFFLOAD_CONFIG.inlineThreshold * CLAUDE_MD_INLINE_BUDGET_SHARE; + expect( + tokens, + `CLAUDE.md ≈ ${tokens} tokens; budget is ${budget} (${CLAUDE_MD_INLINE_BUDGET_SHARE} × CONTEXT_OFFLOAD_CONFIG.inlineThreshold). Past the threshold src/backends/shared/contextFiles.ts offloads it whenever the contextFiles step runs.`, + ).toBeLessThan(budget); + }); + + it('CLAUDE.md has no @-imports outside code spans', () => { + expect( + stripCode(claude), + 'CASCADE workers cat CLAUDE.md raw, so @imports never expand there (and Claude Code loads them at launch anyway). Link the file in the pointer table instead.', + ).not.toMatch(CLAUDE_MD_IMPORT_PATTERN); + }); + + it('CLAUDE.md and area docs carry no incident narrative', () => { + for (const filePath of [claudePath, ...areaDocs]) { + const content = readDoc(filePath); + for (const pattern of NARRATIVE_PATTERNS) { + expect( + content, + `${filePath} should not match ${pattern} — ticket IDs, spec numbers and dates belong in CHANGELOG.md`, + ).not.toMatch(pattern); + } + } + }); + + it('docs/areas exists and every area doc is short and declares its scope', () => { + expect(areaDocs.length, 'docs/areas/ should hold at least one area doc').toBeGreaterThan(0); + for (const filePath of areaDocs) { + const content = readDoc(filePath); + const lines = content.split('\n').length; + expect( + lines, + `${filePath} is ${lines} lines; keep area docs ≤ ${AREA_DOC_MAX_LINES} — link the reference doc instead of restating it`, + ).toBeLessThanOrEqual(AREA_DOC_MAX_LINES); + + const firstBodyLine = content + .split('\n') + .find((line) => line.trim() && !line.startsWith('# ')); + expect( + firstBodyLine, + `${filePath} should open with an "**Applies to:**" scope line`, + ).toMatch(/^\*\*Applies to:\*\*/); + } + }); + + it('CLAUDE.md links every area doc', () => { + for (const filePath of areaDocs) { + const rel = `./docs/areas/${path.basename(filePath)}`; + expect(claude, `CLAUDE.md should point at ${rel}`).toContain(rel); + } + }); + }); }); diff --git a/tests/unit/repo-hygiene.test.ts b/tests/unit/repo-hygiene.test.ts index 45ae9c1f..95b086ec 100644 --- a/tests/unit/repo-hygiene.test.ts +++ b/tests/unit/repo-hygiene.test.ts @@ -115,6 +115,16 @@ describe('open-source readiness', () => { it('requires Node.js 22+', () => { expect(pkg.engines.node).toBe('>=22.0.0'); }); + + it('root and web/ share the same zod major', () => { + // web/tsconfig.json compiles ../src/api and ../src/db; if the majors diverge, + // z.infer<> silently computes different types in backend vs frontend compilation. + const webPkg = JSON.parse(readRoot('web/package.json')); + const major = (range: string) => range.replace(/^[^\d]*/, '').split('.')[0]; + expect(major(webPkg.dependencies.zod), 'bump zod in root and web/ together').toBe( + major(pkg.dependencies.zod), + ); + }); }); describe('config/projects.json', () => {